mirror of
https://github.com/SteamDeckHomebrew/decky-loader.git
synced 2026-07-01 15:29:54 +00:00
Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| caf37d681f | |||
| 93151e4e5e | |||
| d6f336d84b | |||
| 4777963b65 | |||
| fc193f98db | |||
| a07e4d6fe6 | |||
| 4ab7d97ab2 | |||
| 15a6f7fdb8 | |||
| 7d2cff8745 | |||
| ee5ed3faf0 | |||
| 0f36e87cce | |||
| fd325ef1cc | |||
| faf46ba533 | |||
| 94ec434eae | |||
| a223efd6f5 | |||
| 395e45167d | |||
| 0dd0d9f4bd | |||
| 3e5404abdd | |||
| 46abc5a266 | |||
| 88e1e9b869 | |||
| fc0089f7a5 | |||
| d335562328 | |||
| f9624a0859 | |||
| 97bb3fa4c8 | |||
| 611245aec9 | |||
| e1807e8c75 | |||
| b94cfe32d9 | |||
| f1e679c3fb | |||
| e1b138bcbd | |||
| c6be8f6c14 | |||
| ac086cf59e | |||
| 3e120ea312 | |||
| 0b718daa47 | |||
| 0929b9c5cb | |||
| 43b2269ea7 | |||
| 0c4e27cd34 | |||
| 36cf85b08a | |||
| 994da868af | |||
| 2e53fb217a | |||
| c2b76d9099 | |||
| c05e8f9ae0 | |||
| 2dce0646bd | |||
| 6569f1b268 | |||
| 3ebaac6752 | |||
| cbbd564860 | |||
| 635edf7f5b | |||
| 1b6e18bcb3 | |||
| 0ad0016c62 | |||
| a2716449f9 | |||
| 649eed89c9 | |||
| 83680fffa2 | |||
| d695b90baf | |||
| 5fdcc56409 | |||
| 915997d149 | |||
| e8b4c4a307 | |||
| e92b66068a | |||
| b72b327610 | |||
| b8fdff8093 | |||
| 880b4c2f8f | |||
| 34af340009 | |||
| 80b6115f6f | |||
| 3bed83697e | |||
| 0ffef6e4bf | |||
| 8810a014f3 | |||
| 385552451b | |||
| c2c9d11c66 | |||
| 0474095a40 | |||
| 346f80beb3 | |||
| 2a6bf75f02 | |||
| f73918c902 | |||
| ea35af2050 | |||
| 6232e3da58 | |||
| 35e46f9ccb | |||
| 2b9a80c151 | |||
| a90ed38c89 | |||
| 3653cf5640 | |||
| 0db45ca71e | |||
| 16681fabb5 | |||
| c210523a22 | |||
| 5d8601347a | |||
| 1e02fcf394 | |||
| f923306a7f |
@@ -0,0 +1,74 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: File a bug/issue
|
||||||
|
title: "[BUG] <title>"
|
||||||
|
labels: [bug]
|
||||||
|
body:
|
||||||
|
- type: checkboxes
|
||||||
|
id: low-effort-checks
|
||||||
|
attributes:
|
||||||
|
label: Please confirm
|
||||||
|
description: Issues without all checks may be ignored/closed.
|
||||||
|
options:
|
||||||
|
- label: I have searched existing issues
|
||||||
|
- label: This issue is not a duplicate of an existing one
|
||||||
|
- label: I have checked the [common issues section in the readme file](https://github.com/SteamDeckHomebrew/decky-loader#-common-issues)
|
||||||
|
- label: I have attached logs to this bug report (failure to include logs will mean your issue will not be responded too).
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Bug Report Description
|
||||||
|
description: A clear and concise description of what the bug is and if possible, the steps you used to get to the bug. If appropriate, include screenshots or videos.
|
||||||
|
placeholder: |
|
||||||
|
When I try to use ...
|
||||||
|
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Expected Behaviour
|
||||||
|
description: A brief description of the expected behavior.
|
||||||
|
placeholder: It should be ...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: SteamOS version
|
||||||
|
# description: Can be found with `uname -a`
|
||||||
|
# placeholder: "Linux steamdeck 5.13.0-valve36-1-neptune #1 SMP PREEMPT Mon, 19 Dec 2022 23:39:41 +0000 x86_64 GNU/Linux"
|
||||||
|
placeholder: "SteamOS 3.4.3 Stable"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
attributes:
|
||||||
|
label: Selected Update Channel
|
||||||
|
description: Which branch of Decky are you on?
|
||||||
|
multiple: false
|
||||||
|
options:
|
||||||
|
- Stable
|
||||||
|
- Prerelease
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: Have you modified the read-only filesystem at any point?
|
||||||
|
description: Describe how here, if you haven't done anything you can leave this blank
|
||||||
|
placeholder: Yes, I've installed neofetch via pacman.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Logs
|
||||||
|
description: Please reboot your deck (if possible) when attempting to recreate the issue, then run ``cd ~ && journalctl -b0 -u plugin_loader.service > deckylog.txt``. This will save the log file to ``~`` aka ``/home/deck``. Please upload the file here
|
||||||
|
placeholder: deckylog.txt
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Steam Deck Homebrew Discord Server
|
||||||
|
url: https://discord.gg/ZU74G2NJzk
|
||||||
|
about: Please ask and answer questions here.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
name: Feature request
|
||||||
|
description: Request a new feature (NOT A PLUGIN)
|
||||||
|
title: "[Request] <title>"
|
||||||
|
labels: [feature request]
|
||||||
|
body:
|
||||||
|
- type: checkboxes
|
||||||
|
id: low-effort-checks
|
||||||
|
attributes:
|
||||||
|
label: Please confirm
|
||||||
|
description: Issues without all checks may be ignored/closed.
|
||||||
|
options:
|
||||||
|
- label: I have searched existing issues
|
||||||
|
- label: This issue is not a duplicate of an existing one
|
||||||
|
- label: This is not a request for a plugin
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Feature Request Description
|
||||||
|
description: A clear and concise description of what the new feature.
|
||||||
|
placeholder: |
|
||||||
|
Decky plugins should be sortable in the quick access menu
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Further Description
|
||||||
|
description: A further explanation of the feature. If appropriate, include screenshots or videos.
|
||||||
|
placeholder: |
|
||||||
|
This would help make the UI clearer and easier to use as there is less clutter in the QAM.
|
||||||
|
It would also make it faster to access plugins that are used more.
|
||||||
|
|
||||||
|
This could be implemented by adding ...
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
@@ -31,7 +31,7 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build PluginLoader
|
name: Build PluginLoader
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-20.04
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Print input
|
- name: Print input
|
||||||
@@ -69,7 +69,7 @@ jobs:
|
|||||||
run: pnpm run build
|
run: pnpm run build
|
||||||
|
|
||||||
- name: Build Python Backend 🛠️
|
- name: Build Python Backend 🛠️
|
||||||
run: pyinstaller --noconfirm --onefile --name "PluginLoader" --add-data ./backend/static:/static --add-data ./backend/legacy:/legacy ./backend/*.py
|
run: pyinstaller --noconfirm --onefile --name "PluginLoader" --add-data ./backend/static:/static --add-data ./backend/legacy:/legacy --add-data ./plugin:/plugin ./backend/*.py
|
||||||
|
|
||||||
- name: Upload package artifact ⬆️
|
- name: Upload package artifact ⬆️
|
||||||
if: ${{ !env.ACT }}
|
if: ${{ !env.ACT }}
|
||||||
@@ -84,6 +84,49 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: ./dist/PluginLoader
|
path: ./dist/PluginLoader
|
||||||
|
|
||||||
|
build-win:
|
||||||
|
name: Build PluginLoader for Win
|
||||||
|
runs-on: windows-2022
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout 🧰
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up NodeJS 18 💎
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 18
|
||||||
|
|
||||||
|
- name: Set up Python 3.10.2 🐍
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: "3.10.2"
|
||||||
|
|
||||||
|
- name: Install Python dependencies ⬇️
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install pyinstaller==5.5
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
- name: Install JS dependencies ⬇️
|
||||||
|
working-directory: ./frontend
|
||||||
|
run: |
|
||||||
|
npm i -g pnpm
|
||||||
|
pnpm i --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build JS Frontend 🛠️
|
||||||
|
working-directory: ./frontend
|
||||||
|
run: pnpm run build
|
||||||
|
|
||||||
|
- name: Build Python Backend 🛠️
|
||||||
|
run: pyinstaller --noconfirm --onefile --name "PluginLoader" --add-data "./backend/static;/static" --add-data "./backend/legacy;/legacy" --add-data "./plugin;/plugin" ./backend/main.py
|
||||||
|
|
||||||
|
- name: Upload package artifact ⬆️
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: PluginLoader Win
|
||||||
|
path: ./dist/PluginLoader.exe
|
||||||
|
|
||||||
release:
|
release:
|
||||||
name: Release stable version of the package
|
name: Release stable version of the package
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release == 'release' }}
|
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release == 'release' }}
|
||||||
@@ -130,9 +173,7 @@ jobs:
|
|||||||
OUT=$(semver bump ${{github.event.inputs.bump}} "$OUT")
|
OUT=$(semver bump ${{github.event.inputs.bump}} "$OUT")
|
||||||
printf "OUT: ${OUT}\n"
|
printf "OUT: ${OUT}\n"
|
||||||
else
|
else
|
||||||
printf "no type selected, defaulting to patch.\n"
|
printf "no type selected, not bumping for release.\n"
|
||||||
OUT=$(semver bump patch "$OUT")
|
|
||||||
printf "OUT: ${OUT}\n"
|
|
||||||
fi
|
fi
|
||||||
elif [[ ! "$VERSION" =~ "-pre" ]]; then
|
elif [[ ! "$VERSION" =~ "-pre" ]]; then
|
||||||
printf "previous tag is a release, bumping by selected type.\n"
|
printf "previous tag is a release, bumping by selected type.\n"
|
||||||
@@ -159,7 +200,7 @@ jobs:
|
|||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' && !env.ACT }}
|
if: ${{ github.event_name == 'workflow_dispatch' && !env.ACT }}
|
||||||
with:
|
with:
|
||||||
name: Prerelease ${{ steps.ready_tag.outputs.tag_name }}
|
name: Release ${{ steps.ready_tag.outputs.tag_name }}
|
||||||
tag_name: ${{ steps.ready_tag.outputs.tag_name }}
|
tag_name: ${{ steps.ready_tag.outputs.tag_name }}
|
||||||
files: ./dist/PluginLoader
|
files: ./dist/PluginLoader
|
||||||
prerelease: false
|
prerelease: false
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
name: Push Updated Plugin Stub to Template
|
||||||
|
|
||||||
|
on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
copy-stub:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@8230315d06ad95c617244d2f265d237a1682d445
|
||||||
|
with:
|
||||||
|
ref: ${{ github.sha }}
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@v35.6.3
|
||||||
|
with:
|
||||||
|
separator: ","
|
||||||
|
files: |
|
||||||
|
plugin/*
|
||||||
|
|
||||||
|
- name: Is stub changed
|
||||||
|
id: changed-stub
|
||||||
|
run: |
|
||||||
|
STUB_CHANGED="false"
|
||||||
|
PATHS=(plugin plugin/decky_plugin.pyi)
|
||||||
|
SHA=${{ github.sha }}
|
||||||
|
SHA_PREV=HEAD^
|
||||||
|
FILES=$(git diff $SHA_PREV..$SHA --name-only -- ${PATHS[@]} | jq -Rsc 'split("\n")[:-1] | join (",")')
|
||||||
|
if [[ "$FILES" == *"plugin/decky_plugin.pyi"* ]]; then
|
||||||
|
$STUB_CHANGED="true"
|
||||||
|
echo "Stub has changed, pushing updated stub"
|
||||||
|
else
|
||||||
|
echo "Stub has not changed, exiting."
|
||||||
|
echo "has_changed=$STUB_CHANGED" >> $GITHUB_OUTPUT
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "has_changed=$STUB_CHANGED" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Push updated stub
|
||||||
|
if: steps.changed-stub.outputs.has_changed == true
|
||||||
|
uses: dmnemec/copy_file_to_another_repo_action@bbebd3da22e4a37d04dca5f782edd5201cb97083
|
||||||
|
env:
|
||||||
|
API_TOKEN_GITHUB: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
source_file: 'plugin/decky_plugin.pyi'
|
||||||
|
destination_repo: 'SteamDeckHomebrew/decky-plugin-template'
|
||||||
|
user_email: '11465594+TrainDoctor@users.noreply.github.com'
|
||||||
|
user_name: 'TrainDoctor'
|
||||||
|
commit_message: 'Updated template with latest plugin stub changes'
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
name: Lint
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: Run linters
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2 # Check out the repository first.
|
||||||
|
- name: Run prettier (JavaScript & TypeScript)
|
||||||
|
run: |
|
||||||
|
pushd frontend
|
||||||
|
npm install
|
||||||
|
npm run lint
|
||||||
Vendored
+1
-1
@@ -4,4 +4,4 @@
|
|||||||
"deckpass" : "ssap",
|
"deckpass" : "ssap",
|
||||||
"deckkey" : "-i ${env:HOME}/.ssh/id_rsa",
|
"deckkey" : "-i ${env:HOME}/.ssh/id_rsa",
|
||||||
"deckdir" : "/home/deck"
|
"deckdir" : "/home/deck"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<h1 align="center">
|
<h1 align="center">
|
||||||
<a name="logo" href="https://deckbrew.xyz/"><img src="https://deckbrew.xyz/logo.png" alt="Deckbrew logo" width="200"></a>
|
<a name="logo" href="https://deckbrew.xyz/"><img src="https://deckbrew.xyz/static/icon-45ca1f5aea376a9ad37e92db906f283e.png" alt="Deckbrew logo" width="200"></a>
|
||||||
<br>
|
<br>
|
||||||
Decky Loader
|
Decky Loader
|
||||||
|
<br>
|
||||||
|
<a name="download button" href="https://github.com/SteamDeckHomebrew/decky-installer/releases/latest/download/decky_installer.desktop"><img src="./docs/images/download_button.svg" alt="Download decky" width="350px" style="padding-top: 15px;"></a>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -9,7 +11,7 @@
|
|||||||
<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/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/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://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>
|
<a href="https://deckbrew.xyz/discord"><img src="https://img.shields.io/discord/960281551428522045?color=%235865F2&label=discord" /></a>
|
||||||
<br>
|
<br>
|
||||||
<br>
|
<br>
|
||||||
<img src="https://media.discordapp.net/attachments/966017112244125756/1012466063893610506/main.jpg" alt="Decky screenshot" width="80%">
|
<img src="https://media.discordapp.net/attachments/966017112244125756/1012466063893610506/main.jpg" alt="Decky screenshot" width="80%">
|
||||||
@@ -31,41 +33,40 @@ For more information about Decky Loader as well as documentation and development
|
|||||||
|
|
||||||
### 🤔 Common Issues
|
### 🤔 Common Issues
|
||||||
|
|
||||||
- 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.
|
- 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 or 8080, please change its port to something else or uninstall it.
|
- If you are using any software that uses port 1337 or 8080, please change its port to something else or uninstall it.
|
||||||
|
- Sometimes Decky will disappear on SteamOS updates. This can easily be fixed by just re-running the installer and installing the stable branch again. If this doesn't work, try installing the prerelease instead. If that doesn't work, then [check the existing issues](https://github.com/SteamDeckHomebrew/decky-loader/issues) and if there isn't one then you can [file a new issue](https://github.com/SteamDeckHomebrew/decky-loader/issues/new?assignees=&labels=bug&template=bug_report.yml&title=%5BBUG%5D+%3Ctitle%3E).
|
||||||
|
|
||||||
## 💾 Installation
|
## 💾 Installation
|
||||||
|
- This installation can be done without an admin/sudo password set.
|
||||||
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.
|
1. Prepare a mouse and keyboard if possible.
|
||||||
- Keyboards and mice can be connected to the Steam Deck via USB-C or Bluetooth.
|
- 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.
|
- Many Bluetooth keyboard and mouse apps are available for iOS and Android. (KDE connect is preinstalled on the steam deck)
|
||||||
- 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.
|
- 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.
|
- 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. 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. 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. Navigate to this Github page on a browser of your choice.
|
||||||
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. Download the [installer file](https://github.com/SteamDeckHomebrew/decky-installer/releases/latest/download/decky_installer.desktop).
|
||||||
1. Choose the version of Decky Loader you want to install and paste the following command into the Konsole app.
|
1. Drag the file onto your desktop and double click it to run it.
|
||||||
|
1. Either type your admin password or allow Decky to temporarily set your admin password to `Decky!` (this password will be removed after the installer finishes)
|
||||||
|
1. Choose the version of Decky Loader you want to install.
|
||||||
- **Latest Release**
|
- **Latest Release**
|
||||||
Intended for most users. This is the latest stable version of Decky Loader.
|
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**
|
- **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).
|
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://wiki.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.
|
1. Open the Return to Gaming Mode shortcut on your desktop.
|
||||||
|
|
||||||
|
- There is also a fast install for those who can use Konsole. Run `curl -L https://github.com/SteamDeckHomebrew/decky-installer/releases/latest/download/install_release.sh | sh` and type your password when prompted.
|
||||||
|
|
||||||
### 👋 Uninstallation
|
### 👋 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.
|
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://deckbrew.xyz/discord) 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. 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. 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`.
|
1. Run the installer file again, and select `uninstall decky loader`.
|
||||||
|
- There is also a fast uninstall for those who can use Konsole. Run `curl -L https://github.com/SteamDeckHomebrew/decky-installer/releases/latest/download/uninstall.sh | sh` and type your password when prompted.
|
||||||
|
|
||||||
## 🚀 Getting Started
|
## 🚀 Getting Started
|
||||||
|
|
||||||
@@ -82,15 +83,16 @@ Now that you have Decky Loader installed, you can start using plugins. Each plug
|
|||||||
|
|
||||||
### 🛠️ Plugin Development
|
### 🛠️ 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.
|
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://deckbrew.xyz/discord) if you have any questions.
|
||||||
|
|
||||||
### 🤝 Contributing
|
### 🤝 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).
|
Please consult [the wiki page regarding development](https://wiki.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. Clone the repository using the latest commit to main before starting your PR.
|
||||||
1. In your clone of the repository, run these commands.
|
1. In your clone of the repository, run these commands.
|
||||||
```bash
|
```bash
|
||||||
|
cd frontend
|
||||||
pnpm i
|
pnpm i
|
||||||
pnpm run build
|
pnpm run build
|
||||||
```
|
```
|
||||||
|
|||||||
+2
-2
@@ -26,10 +26,10 @@ cd ..
|
|||||||
|
|
||||||
if [[ "$type" == "release" ]]; then
|
if [[ "$type" == "release" ]]; then
|
||||||
printf "release!\n"
|
printf "release!\n"
|
||||||
act workflow_dispatch -e act/release.json --artifact-server-path act/artifacts
|
act workflow_dispatch -e act/release.json --artifact-server-path act/artifacts --container-architecture linux/amd64
|
||||||
elif [[ "$type" == "prerelease" ]]; then
|
elif [[ "$type" == "prerelease" ]]; then
|
||||||
printf "prerelease!\n"
|
printf "prerelease!\n"
|
||||||
act workflow_dispatch -e act/prerelease.json --artifact-server-path act/artifacts
|
act workflow_dispatch -e act/prerelease.json --artifact-server-path act/artifacts --container-architecture linux/amd64
|
||||||
else
|
else
|
||||||
printf "Release type unspecified/badly specified.\n"
|
printf "Release type unspecified/badly specified.\n"
|
||||||
printf "Options: 'release' or 'prerelease'\n"
|
printf "Options: 'release' or 'prerelease'\n"
|
||||||
|
|||||||
+94
-54
@@ -1,5 +1,7 @@
|
|||||||
# Full imports
|
# Full imports
|
||||||
import json
|
import json
|
||||||
|
# import pprint
|
||||||
|
# from pprint import pformat
|
||||||
|
|
||||||
# Partial imports
|
# Partial imports
|
||||||
from aiohttp import ClientSession, web
|
from aiohttp import ClientSession, web
|
||||||
@@ -10,12 +12,12 @@ from io import BytesIO
|
|||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from os import R_OK, W_OK, path, rename, listdir, access, mkdir
|
from os import R_OK, W_OK, path, rename, listdir, access, mkdir
|
||||||
from shutil import rmtree
|
from shutil import rmtree
|
||||||
from subprocess import call
|
|
||||||
from time import time
|
from time import time
|
||||||
from zipfile import ZipFile
|
from zipfile import ZipFile
|
||||||
|
from localplatform import chown, chmod
|
||||||
|
|
||||||
# Local modules
|
# Local modules
|
||||||
from helpers import get_ssl_context, get_user, get_user_group, download_remote_binary_to_path
|
from helpers import get_ssl_context, download_remote_binary_to_path
|
||||||
from injector import get_gamepadui_tab
|
from injector import get_gamepadui_tab
|
||||||
|
|
||||||
logger = getLogger("Browser")
|
logger = getLogger("Browser")
|
||||||
@@ -28,10 +30,11 @@ class PluginInstallContext:
|
|||||||
self.hash = hash
|
self.hash = hash
|
||||||
|
|
||||||
class PluginBrowser:
|
class PluginBrowser:
|
||||||
def __init__(self, plugin_path, plugins, loader) -> None:
|
def __init__(self, plugin_path, plugins, loader, settings) -> None:
|
||||||
self.plugin_path = plugin_path
|
self.plugin_path = plugin_path
|
||||||
self.plugins = plugins
|
self.plugins = plugins
|
||||||
self.loader = loader
|
self.loader = loader
|
||||||
|
self.settings = settings
|
||||||
self.install_requests = {}
|
self.install_requests = {}
|
||||||
|
|
||||||
def _unzip_to_plugin_dir(self, zip, name, hash):
|
def _unzip_to_plugin_dir(self, zip, name, hash):
|
||||||
@@ -40,11 +43,10 @@ class PluginBrowser:
|
|||||||
return False
|
return False
|
||||||
zip_file = ZipFile(zip)
|
zip_file = ZipFile(zip)
|
||||||
zip_file.extractall(self.plugin_path)
|
zip_file.extractall(self.plugin_path)
|
||||||
plugin_dir = self.find_plugin_folder(name)
|
plugin_dir = path.join(self.plugin_path, 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 not chown(plugin_dir) or not chmod(plugin_dir, 555):
|
||||||
if code_chown != 0 or code_chmod != 0:
|
logger.error(f"chown/chmod exited with a non-zero exit code")
|
||||||
logger.error(f"chown/chmod exited with a non-zero exit code (chown: {code_chown}, chmod: {code_chmod})")
|
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -55,18 +57,18 @@ class PluginBrowser:
|
|||||||
pluginBinPath = path.join(pluginBasePath, 'bin')
|
pluginBinPath = path.join(pluginBasePath, 'bin')
|
||||||
|
|
||||||
if access(packageJsonPath, R_OK):
|
if access(packageJsonPath, R_OK):
|
||||||
with open(packageJsonPath, 'r') as f:
|
with open(packageJsonPath, "r", encoding="utf-8") as f:
|
||||||
packageJson = json.load(f)
|
packageJson = json.load(f)
|
||||||
if "remote_binary" in packageJson and len(packageJson["remote_binary"]) > 0:
|
if "remote_binary" in packageJson and len(packageJson["remote_binary"]) > 0:
|
||||||
# create bin directory if needed.
|
# create bin directory if needed.
|
||||||
rc=call(["chmod", "-R", "777", pluginBasePath])
|
chmod(pluginBasePath, 777)
|
||||||
if access(pluginBasePath, W_OK):
|
if access(pluginBasePath, W_OK):
|
||||||
|
|
||||||
if not path.exists(pluginBinPath):
|
if not path.exists(pluginBinPath):
|
||||||
mkdir(pluginBinPath)
|
mkdir(pluginBinPath)
|
||||||
|
|
||||||
if not access(pluginBinPath, W_OK):
|
if not access(pluginBinPath, W_OK):
|
||||||
rc=call(["chmod", "-R", "777", pluginBinPath])
|
chmod(pluginBinPath, 777)
|
||||||
|
|
||||||
rv = True
|
rv = True
|
||||||
for remoteBinary in packageJson["remote_binary"]:
|
for remoteBinary in packageJson["remote_binary"]:
|
||||||
@@ -78,8 +80,8 @@ class PluginBrowser:
|
|||||||
rv = False
|
rv = False
|
||||||
raise Exception(f"Error Downloading Remote Binary {binName}@{binURL} with hash {binHash} to {path.join(pluginBinPath, binName)}")
|
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])
|
chown(self.plugin_path)
|
||||||
rc=call(["chmod", "-R", "555", pluginBasePath])
|
chmod(pluginBasePath, 555)
|
||||||
else:
|
else:
|
||||||
rv = True
|
rv = True
|
||||||
logger.debug(f"No Remote Binaries to Download")
|
logger.debug(f"No Remote Binaries to Download")
|
||||||
@@ -90,14 +92,15 @@ class PluginBrowser:
|
|||||||
|
|
||||||
return rv
|
return rv
|
||||||
|
|
||||||
|
"""Return the filename (only) for the specified plugin"""
|
||||||
def find_plugin_folder(self, name):
|
def find_plugin_folder(self, name):
|
||||||
for folder in listdir(self.plugin_path):
|
for folder in listdir(self.plugin_path):
|
||||||
try:
|
try:
|
||||||
with open(path.join(self.plugin_path, folder, 'plugin.json'), 'r') as f:
|
with open(path.join(self.plugin_path, folder, 'plugin.json'), "r", encoding="utf-8") as f:
|
||||||
plugin = json.load(f)
|
plugin = json.load(f)
|
||||||
|
|
||||||
if plugin['name'] == name:
|
if plugin['name'] == name:
|
||||||
return str(path.join(self.plugin_path, folder))
|
return folder
|
||||||
except:
|
except:
|
||||||
logger.debug(f"skipping {folder}")
|
logger.debug(f"skipping {folder}")
|
||||||
|
|
||||||
@@ -105,25 +108,41 @@ class PluginBrowser:
|
|||||||
if self.loader.watcher:
|
if self.loader.watcher:
|
||||||
self.loader.watcher.disabled = True
|
self.loader.watcher.disabled = True
|
||||||
tab = await get_gamepadui_tab()
|
tab = await get_gamepadui_tab()
|
||||||
|
plugin_dir = path.join(self.plugin_path, self.find_plugin_folder(name))
|
||||||
try:
|
try:
|
||||||
logger.info("uninstalling " + name)
|
logger.info("uninstalling " + name)
|
||||||
logger.info(" at dir " + self.find_plugin_folder(name))
|
logger.info(" at dir " + plugin_dir)
|
||||||
logger.debug("unloading %s" % str(name))
|
logger.debug("calling frontend unload for %s" % str(name))
|
||||||
await tab.evaluate_js(f"DeckyPluginLoader.unloadPlugin('{name}')")
|
res = await tab.evaluate_js(f"DeckyPluginLoader.unloadPlugin('{name}')")
|
||||||
if self.plugins[name]:
|
logger.debug("result of unload from UI: %s", res)
|
||||||
|
# plugins_snapshot = self.plugins.copy()
|
||||||
|
# snapshot_string = pformat(plugins_snapshot)
|
||||||
|
# logger.debug("current plugins: %s", snapshot_string)
|
||||||
|
if name in self.plugins:
|
||||||
|
logger.debug("Plugin %s was found", name)
|
||||||
self.plugins[name].stop()
|
self.plugins[name].stop()
|
||||||
|
logger.debug("Plugin %s was stopped", name)
|
||||||
del self.plugins[name]
|
del self.plugins[name]
|
||||||
|
logger.debug("Plugin %s was removed from the dictionary", name)
|
||||||
|
current_plugin_order = self.settings.getSetting("pluginOrder")
|
||||||
|
current_plugin_order.remove(name)
|
||||||
|
self.settings.setSetting("pluginOrder", current_plugin_order)
|
||||||
|
logger.debug("Plugin %s was removed from the pluginOrder setting", name)
|
||||||
logger.debug("removing files %s" % str(name))
|
logger.debug("removing files %s" % str(name))
|
||||||
rmtree(self.find_plugin_folder(name))
|
rmtree(plugin_dir)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
logger.warning(f"Plugin {name} not installed, skipping uninstallation")
|
logger.warning(f"Plugin {name} not installed, skipping uninstallation")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Plugin {name} in {self.find_plugin_folder(name)} was not uninstalled")
|
logger.error(f"Plugin {name} in {plugin_dir} was not uninstalled")
|
||||||
logger.error(f"Error at %s", exc_info=e)
|
logger.error(f"Error at %s", exc_info=e)
|
||||||
if self.loader.watcher:
|
if self.loader.watcher:
|
||||||
self.loader.watcher.disabled = False
|
self.loader.watcher.disabled = False
|
||||||
|
|
||||||
async def _install(self, artifact, name, version, hash):
|
async def _install(self, artifact, name, version, hash):
|
||||||
|
# Will be set later in code
|
||||||
|
res_zip = None
|
||||||
|
|
||||||
|
# Check if plugin is installed
|
||||||
isInstalled = False
|
isInstalled = False
|
||||||
if self.loader.watcher:
|
if self.loader.watcher:
|
||||||
self.loader.watcher.disabled = True
|
self.loader.watcher.disabled = True
|
||||||
@@ -133,41 +152,62 @@ class PluginBrowser:
|
|||||||
isInstalled = True
|
isInstalled = True
|
||||||
except:
|
except:
|
||||||
logger.error(f"Failed to determine if {name} is already installed, continuing anyway.")
|
logger.error(f"Failed to determine if {name} is already installed, continuing anyway.")
|
||||||
logger.info(f"Installing {name} (Version: {version})")
|
|
||||||
async with ClientSession() as client:
|
# Check if the file is a local file or a URL
|
||||||
logger.debug(f"Fetching {artifact}")
|
if artifact.startswith("file://"):
|
||||||
res = await client.get(artifact, ssl=get_ssl_context())
|
logger.info(f"Installing {name} from local ZIP file (Version: {version})")
|
||||||
if res.status == 200:
|
res_zip = BytesIO(open(artifact[7:], "rb").read())
|
||||||
logger.debug("Got 200. Reading...")
|
else:
|
||||||
data = await res.read()
|
logger.info(f"Installing {name} from URL (Version: {version})")
|
||||||
logger.debug(f"Read {len(data)} bytes")
|
async with ClientSession() as client:
|
||||||
res_zip = BytesIO(data)
|
logger.debug(f"Fetching {artifact}")
|
||||||
if isInstalled:
|
res = await client.get(artifact, ssl=get_ssl_context())
|
||||||
try:
|
if res.status == 200:
|
||||||
logger.debug("Uninstalling existing plugin...")
|
logger.debug("Got 200. Reading...")
|
||||||
await self.uninstall_plugin(name)
|
data = await res.read()
|
||||||
except:
|
logger.debug(f"Read {len(data)} bytes")
|
||||||
logger.error(f"Plugin {name} could not be uninstalled.")
|
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)
|
|
||||||
else:
|
|
||||||
logger.fatal(f"Failed Downloading Remote Binaries")
|
|
||||||
else:
|
else:
|
||||||
self.log.fatal(f"SHA-256 Mismatch!!!! {name} (Version: {version})")
|
logger.fatal(f"Could not fetch from URL. {await res.text()}")
|
||||||
if self.loader.watcher:
|
|
||||||
self.loader.watcher.disabled = False
|
# Check to make sure we got the file
|
||||||
|
if res_zip is None:
|
||||||
|
logger.fatal(f"Could not fetch {artifact}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# If plugin is installed, uninstall it
|
||||||
|
if isInstalled:
|
||||||
|
try:
|
||||||
|
logger.debug("Uninstalling existing plugin...")
|
||||||
|
await self.uninstall_plugin(name)
|
||||||
|
except:
|
||||||
|
logger.error(f"Plugin {name} could not be uninstalled.")
|
||||||
|
|
||||||
|
# Install the plugin
|
||||||
|
logger.debug("Unzipping...")
|
||||||
|
ret = self._unzip_to_plugin_dir(res_zip, name, hash)
|
||||||
|
if ret:
|
||||||
|
plugin_folder = self.find_plugin_folder(name)
|
||||||
|
plugin_dir = path.join(self.plugin_path, plugin_folder)
|
||||||
|
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)
|
||||||
|
|
||||||
|
current_plugin_order = self.settings.getSetting("pluginOrder")
|
||||||
|
current_plugin_order.append(name)
|
||||||
|
self.settings.setSetting("pluginOrder", current_plugin_order)
|
||||||
|
logger.debug("Plugin %s was added to the pluginOrder setting", name)
|
||||||
|
self.loader.import_plugin(path.join(plugin_dir, "main.py"), plugin_folder)
|
||||||
else:
|
else:
|
||||||
logger.fatal(f"Could not fetch from URL. {await res.text()}")
|
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
|
||||||
|
|
||||||
async def request_plugin_install(self, artifact, name, version, hash):
|
async def request_plugin_install(self, artifact, name, version, hash):
|
||||||
request_id = str(time())
|
request_id = str(time())
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
class UserType(Enum):
|
||||||
|
HOST_USER = 1
|
||||||
|
EFFECTIVE_USER = 2
|
||||||
|
ROOT = 3
|
||||||
+101
-65
@@ -1,27 +1,28 @@
|
|||||||
import re
|
import re
|
||||||
import ssl
|
import ssl
|
||||||
import subprocess
|
|
||||||
import uuid
|
import uuid
|
||||||
import os
|
import os
|
||||||
from subprocess import check_output
|
import sys
|
||||||
from time import sleep
|
import subprocess
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
import certifi
|
import certifi
|
||||||
from aiohttp.web import Response, middleware
|
from aiohttp.web import Response, middleware
|
||||||
from aiohttp import ClientSession
|
from aiohttp import ClientSession
|
||||||
|
import localplatform
|
||||||
|
from customtypes import UserType
|
||||||
|
from logging import getLogger
|
||||||
|
|
||||||
REMOTE_DEBUGGER_UNIT = "steam-web-debug-portforward.service"
|
REMOTE_DEBUGGER_UNIT = "steam-web-debug-portforward.service"
|
||||||
|
|
||||||
# global vars
|
# global vars
|
||||||
csrf_token = str(uuid.uuid4())
|
csrf_token = str(uuid.uuid4())
|
||||||
ssl_ctx = ssl.create_default_context(cafile=certifi.where())
|
ssl_ctx = ssl.create_default_context(cafile=certifi.where())
|
||||||
user = None
|
|
||||||
group = None
|
|
||||||
|
|
||||||
assets_regex = re.compile("^/plugins/.*/assets/.*")
|
assets_regex = re.compile("^/plugins/.*/assets/.*")
|
||||||
frontend_regex = re.compile("^/frontend/.*")
|
frontend_regex = re.compile("^/frontend/.*")
|
||||||
|
logger = getLogger("Main")
|
||||||
|
|
||||||
def get_ssl_context():
|
def get_ssl_context():
|
||||||
return ssl_ctx
|
return ssl_ctx
|
||||||
@@ -35,57 +36,41 @@ async def csrf_middleware(request, handler):
|
|||||||
return await handler(request)
|
return await handler(request)
|
||||||
return Response(text='Forbidden', status='403')
|
return Response(text='Forbidden', status='403')
|
||||||
|
|
||||||
# Get the user by checking for the first logged in user. As this is run
|
# Get the default homebrew path unless a home_path is specified. home_path argument is deprecated
|
||||||
# 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:
|
def get_homebrew_path(home_path = None) -> str:
|
||||||
if home_path == None:
|
return localplatform.get_unprivileged_path()
|
||||||
raise ValueError("Home path not defined, homebrew dir cannot be determined.")
|
|
||||||
else:
|
# Recursively create path and chown as user
|
||||||
return str(home_path+"/homebrew")
|
def mkdir_as_user(path):
|
||||||
# return str(home_path+"/homebrew")
|
path = os.path.realpath(path)
|
||||||
|
os.makedirs(path, exist_ok=True)
|
||||||
|
localplatform.chown(path)
|
||||||
|
|
||||||
|
# Fetches the version of loader
|
||||||
|
def get_loader_version() -> str:
|
||||||
|
try:
|
||||||
|
with open(os.path.join(os.getcwd(), ".loader.version"), "r", encoding="utf-8") as version_file:
|
||||||
|
return version_file.readline().strip()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warn(f"Failed to execute get_loader_version(): {str(e)}")
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
# returns the appropriate system python paths
|
||||||
|
def get_system_pythonpaths() -> list[str]:
|
||||||
|
extra_args = {}
|
||||||
|
|
||||||
|
if localplatform.ON_LINUX:
|
||||||
|
# run as normal normal user to also include user python paths
|
||||||
|
extra_args["user"] = localplatform.localplatform._get_user_id()
|
||||||
|
extra_args["env"] = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(["python3" if localplatform.ON_LINUX else "python", "-c", "import sys; print('\\n'.join(x for x in sys.path if x))"],
|
||||||
|
capture_output=True, **extra_args)
|
||||||
|
return [x.strip() for x in proc.stdout.decode().strip().split("\n")]
|
||||||
|
except Exception as e:
|
||||||
|
logger.warn(f"Failed to execute get_system_pythonpaths(): {str(e)}")
|
||||||
|
return []
|
||||||
|
|
||||||
# Download Remote Binaries to local Plugin
|
# Download Remote Binaries to local Plugin
|
||||||
async def download_remote_binary_to_path(url, binHash, path) -> bool:
|
async def download_remote_binary_to_path(url, binHash, path) -> bool:
|
||||||
@@ -111,16 +96,67 @@ async def download_remote_binary_to_path(url, binHash, path) -> bool:
|
|||||||
|
|
||||||
return rv
|
return rv
|
||||||
|
|
||||||
|
# Deprecated
|
||||||
|
def set_user():
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Deprecated
|
||||||
|
def set_user_group() -> str:
|
||||||
|
return get_user_group()
|
||||||
|
|
||||||
|
#########
|
||||||
|
# Below is legacy code, provided for backwards compatibility. This will break on windows
|
||||||
|
#########
|
||||||
|
|
||||||
|
# Get the user id hosting the plugin loader
|
||||||
|
def get_user_id() -> int:
|
||||||
|
return localplatform.localplatform._get_user_id()
|
||||||
|
|
||||||
|
# Get the user hosting the plugin loader
|
||||||
|
def get_user() -> str:
|
||||||
|
return localplatform.localplatform._get_user()
|
||||||
|
|
||||||
|
# Get the effective user id of the running process
|
||||||
|
def get_effective_user_id() -> int:
|
||||||
|
return localplatform.localplatform._get_effective_user_id()
|
||||||
|
|
||||||
|
# Get the effective user of the running process
|
||||||
|
def get_effective_user() -> str:
|
||||||
|
return localplatform.localplatform._get_effective_user()
|
||||||
|
|
||||||
|
# Get the effective user group id of the running process
|
||||||
|
def get_effective_user_group_id() -> int:
|
||||||
|
return localplatform.localplatform._get_effective_user_group_id()
|
||||||
|
|
||||||
|
# Get the effective user group of the running process
|
||||||
|
def get_effective_user_group() -> str:
|
||||||
|
return localplatform.localplatform._get_effective_user_group()
|
||||||
|
|
||||||
|
# Get the user owner of the given file path.
|
||||||
|
def get_user_owner(file_path) -> str:
|
||||||
|
return localplatform.localplatform._get_user_owner(file_path)
|
||||||
|
|
||||||
|
# Get the user group of the given file path.
|
||||||
|
def get_user_group(file_path) -> str:
|
||||||
|
return localplatform.localplatform._get_user_group(file_path)
|
||||||
|
|
||||||
|
# Get the group id of the user hosting the plugin loader
|
||||||
|
def get_user_group_id() -> int:
|
||||||
|
return localplatform.localplatform._get_user_group_id()
|
||||||
|
|
||||||
|
# Get the group of the user hosting the plugin loader
|
||||||
|
def get_user_group() -> str:
|
||||||
|
return localplatform.localplatform._get_user_group()
|
||||||
|
|
||||||
|
# Get the default home path unless a user is specified
|
||||||
|
def get_home_path(username = None) -> str:
|
||||||
|
return localplatform.get_home_path(UserType.ROOT if username == "root" else UserType.HOST_USER)
|
||||||
|
|
||||||
async def is_systemd_unit_active(unit_name: str) -> bool:
|
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 await localplatform.service_active(unit_name)
|
||||||
return res.returncode == 0
|
|
||||||
|
|
||||||
async def stop_systemd_unit(unit_name: str) -> subprocess.CompletedProcess:
|
async def stop_systemd_unit(unit_name: str) -> bool:
|
||||||
cmd = ["systemctl", "stop", unit_name]
|
return await localplatform.service_stop(unit_name)
|
||||||
|
|
||||||
return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
async def start_systemd_unit(unit_name: str) -> bool:
|
||||||
|
return await localplatform.service_start(unit_name)
|
||||||
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)
|
|
||||||
+16
-2
@@ -111,7 +111,7 @@ class Tab:
|
|||||||
"method": "Page.disable",
|
"method": "Page.disable",
|
||||||
}, False)
|
}, False)
|
||||||
|
|
||||||
async def refresh(self):
|
async def refresh(self, manage_socket=True):
|
||||||
try:
|
try:
|
||||||
if manage_socket:
|
if manage_socket:
|
||||||
await self.open_websocket()
|
await self.open_websocket()
|
||||||
@@ -394,9 +394,15 @@ async def get_tab_lambda(test) -> Tab:
|
|||||||
raise ValueError(f"Tab not found by lambda")
|
raise ValueError(f"Tab not found by lambda")
|
||||||
return tab
|
return tab
|
||||||
|
|
||||||
|
SHARED_CTX_NAMES = ["SharedJSContext", "Steam Shared Context presented by Valve™", "Steam", "SP"]
|
||||||
|
DO_NOT_CLOSE_URL = "Valve Steam Gamepad/default" # Steam Big Picture Mode tab
|
||||||
|
|
||||||
|
def tab_is_gamepadui(t: Tab) -> bool:
|
||||||
|
return "https://steamloopback.host/routes/" in t.url and t.title in SHARED_CTX_NAMES
|
||||||
|
|
||||||
async def get_gamepadui_tab() -> Tab:
|
async def get_gamepadui_tab() -> Tab:
|
||||||
tabs = await get_tabs()
|
tabs = await get_tabs()
|
||||||
tab = next((i for i in tabs if ("https://steamloopback.host/routes/" in i.url and (i.title == "Steam" or i.title == "SP"))), None)
|
tab = next((i for i in tabs if tab_is_gamepadui(i)), None)
|
||||||
if not tab:
|
if not tab:
|
||||||
raise ValueError(f"GamepadUI Tab not found")
|
raise ValueError(f"GamepadUI Tab not found")
|
||||||
return tab
|
return tab
|
||||||
@@ -405,3 +411,11 @@ async def inject_to_tab(tab_name, js, run_async=False):
|
|||||||
tab = await get_tab(tab_name)
|
tab = await get_tab(tab_name)
|
||||||
|
|
||||||
return await tab.evaluate_js(js, run_async)
|
return await tab.evaluate_js(js, run_async)
|
||||||
|
|
||||||
|
async def close_old_tabs():
|
||||||
|
tabs = await get_tabs()
|
||||||
|
for t in tabs:
|
||||||
|
if not t.title or (t.title not in SHARED_CTX_NAMES and DO_NOT_CLOSE_URL not in t.url):
|
||||||
|
logger.debug("Closing tab: " + getattr(t, "title", "Untitled"))
|
||||||
|
await t.close()
|
||||||
|
await sleep(0.5)
|
||||||
|
|||||||
+5
-11
@@ -6,19 +6,13 @@ from pathlib import Path
|
|||||||
from traceback import print_exc
|
from traceback import print_exc
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from genericpath import exists
|
from os.path import exists
|
||||||
from watchdog.events import RegexMatchingEventHandler
|
from watchdog.events import RegexMatchingEventHandler
|
||||||
from watchdog.utils import UnsupportedLibc
|
from watchdog.observers import Observer
|
||||||
|
|
||||||
try:
|
|
||||||
from watchdog.observers.inotify import InotifyObserver as Observer
|
|
||||||
except UnsupportedLibc:
|
|
||||||
from watchdog.observers.fsevents import FSEventsObserver as Observer
|
|
||||||
|
|
||||||
from injector import get_tab, get_gamepadui_tab
|
from injector import get_tab, get_gamepadui_tab
|
||||||
from plugin import PluginWrapper
|
from plugin import PluginWrapper
|
||||||
|
|
||||||
|
|
||||||
class FileChangeHandler(RegexMatchingEventHandler):
|
class FileChangeHandler(RegexMatchingEventHandler):
|
||||||
def __init__(self, queue, plugin_path) -> None:
|
def __init__(self, queue, plugin_path) -> None:
|
||||||
super().__init__(regexes=[r'^.*?dist\/index\.js$', r'^.*?main\.py$'])
|
super().__init__(regexes=[r'^.*?dist\/index\.js$', r'^.*?main\.py$'])
|
||||||
@@ -118,7 +112,7 @@ class Loader:
|
|||||||
def handle_frontend_bundle(self, request):
|
def handle_frontend_bundle(self, request):
|
||||||
plugin = self.plugins[request.match_info["plugin_name"]]
|
plugin = self.plugins[request.match_info["plugin_name"]]
|
||||||
|
|
||||||
with open(path.join(self.plugin_path, plugin.plugin_directory, "dist/index.js"), 'r') as bundle:
|
with open(path.join(self.plugin_path, plugin.plugin_directory, "dist/index.js"), "r", encoding="utf-8") as bundle:
|
||||||
return web.Response(text=bundle.read(), content_type="application/javascript")
|
return web.Response(text=bundle.read(), content_type="application/javascript")
|
||||||
|
|
||||||
def import_plugin(self, file, plugin_directory, refresh=False, batch=False):
|
def import_plugin(self, file, plugin_directory, refresh=False, batch=False):
|
||||||
@@ -186,7 +180,7 @@ class Loader:
|
|||||||
"""
|
"""
|
||||||
async def load_plugin_main_view(self, request):
|
async def load_plugin_main_view(self, request):
|
||||||
plugin = self.plugins[request.match_info["name"]]
|
plugin = self.plugins[request.match_info["name"]]
|
||||||
with open(path.join(self.plugin_path, plugin.plugin_directory, plugin.main_view_html), 'r') as template:
|
with open(path.join(self.plugin_path, plugin.plugin_directory, plugin.main_view_html), "r", encoding="utf-8") as template:
|
||||||
template_data = template.read()
|
template_data = template.read()
|
||||||
ret = f"""
|
ret = f"""
|
||||||
<script src="/legacy/library.js"></script>
|
<script src="/legacy/library.js"></script>
|
||||||
@@ -202,7 +196,7 @@ class Loader:
|
|||||||
self.logger.info(path)
|
self.logger.info(path)
|
||||||
ret = ""
|
ret = ""
|
||||||
file_path = path.join(self.plugin_path, plugin.plugin_directory, route_path)
|
file_path = path.join(self.plugin_path, plugin.plugin_directory, route_path)
|
||||||
with open(file_path, 'r') as resource_data:
|
with open(file_path, "r", encoding="utf-8") as resource_data:
|
||||||
ret = resource_data.read()
|
ret = resource_data.read()
|
||||||
|
|
||||||
return web.Response(text=ret)
|
return web.Response(text=ret)
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import platform, os
|
||||||
|
|
||||||
|
ON_WINDOWS = platform.system() == "Windows"
|
||||||
|
ON_LINUX = not ON_WINDOWS
|
||||||
|
|
||||||
|
if ON_WINDOWS:
|
||||||
|
from localplatformwin import *
|
||||||
|
import localplatformwin as localplatform
|
||||||
|
else:
|
||||||
|
from localplatformlinux import *
|
||||||
|
import localplatformlinux as localplatform
|
||||||
|
|
||||||
|
def get_privileged_path() -> str:
|
||||||
|
'''Get path accessible by elevated user. Holds plugins, decky loader and decky loader configs'''
|
||||||
|
return localplatform.get_privileged_path()
|
||||||
|
|
||||||
|
def get_unprivileged_path() -> str:
|
||||||
|
'''Get path accessible by non-elevated user. Holds plugin configuration, plugin data and plugin logs. Externally referred to as the 'Homebrew' directory'''
|
||||||
|
return localplatform.get_unprivileged_path()
|
||||||
|
|
||||||
|
def get_unprivileged_user() -> str:
|
||||||
|
'''Get user that should own files made in unprivileged path'''
|
||||||
|
return localplatform.get_unprivileged_user()
|
||||||
|
|
||||||
|
def get_chown_plugin_path() -> bool:
|
||||||
|
return os.getenv("CHOWN_PLUGIN_PATH", "1") == "1"
|
||||||
|
|
||||||
|
def get_server_host() -> str:
|
||||||
|
return os.getenv("SERVER_HOST", "127.0.0.1")
|
||||||
|
|
||||||
|
def get_server_port() -> int:
|
||||||
|
return int(os.getenv("SERVER_PORT", "1337"))
|
||||||
|
|
||||||
|
def get_live_reload() -> bool:
|
||||||
|
os.getenv("LIVE_RELOAD", "1") == "1"
|
||||||
|
|
||||||
|
def get_log_level() -> int:
|
||||||
|
return {"CRITICAL": 50, "ERROR": 40, "WARNING": 30, "INFO": 20, "DEBUG": 10}[
|
||||||
|
os.getenv("LOG_LEVEL", "INFO")
|
||||||
|
]
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import os, pwd, grp, sys, logging
|
||||||
|
from subprocess import call, run, DEVNULL, PIPE, STDOUT
|
||||||
|
from customtypes import UserType
|
||||||
|
|
||||||
|
logger = logging.getLogger("localplatform")
|
||||||
|
|
||||||
|
# Get the user id hosting the plugin loader
|
||||||
|
def _get_user_id() -> int:
|
||||||
|
return pwd.getpwnam(_get_user()).pw_uid
|
||||||
|
|
||||||
|
# Get the user hosting the plugin loader
|
||||||
|
def _get_user() -> str:
|
||||||
|
return get_unprivileged_user()
|
||||||
|
|
||||||
|
# Get the effective user id of the running process
|
||||||
|
def _get_effective_user_id() -> int:
|
||||||
|
return os.geteuid()
|
||||||
|
|
||||||
|
# Get the effective user of the running process
|
||||||
|
def _get_effective_user() -> str:
|
||||||
|
return pwd.getpwuid(_get_effective_user_id()).pw_name
|
||||||
|
|
||||||
|
# Get the effective user group id of the running process
|
||||||
|
def _get_effective_user_group_id() -> int:
|
||||||
|
return os.getegid()
|
||||||
|
|
||||||
|
# Get the effective user group of the running process
|
||||||
|
def _get_effective_user_group() -> str:
|
||||||
|
return grp.getgrgid(_get_effective_user_group_id()).gr_name
|
||||||
|
|
||||||
|
# Get the user owner of the given file path.
|
||||||
|
def _get_user_owner(file_path) -> str:
|
||||||
|
return pwd.getpwuid(os.stat(file_path).st_uid).pw_name
|
||||||
|
|
||||||
|
# Get the user group of the given file path.
|
||||||
|
def _get_user_group(file_path) -> str:
|
||||||
|
return grp.getgrgid(os.stat(file_path).st_gid).gr_name
|
||||||
|
|
||||||
|
# Get the group id of the user hosting the plugin loader
|
||||||
|
def _get_user_group_id() -> int:
|
||||||
|
return pwd.getpwuid(_get_user_id()).pw_gid
|
||||||
|
|
||||||
|
# Get the group of the user hosting the plugin loader
|
||||||
|
def _get_user_group() -> str:
|
||||||
|
return grp.getgrgid(_get_user_group_id()).gr_name
|
||||||
|
|
||||||
|
def chown(path : str, user : UserType = UserType.HOST_USER, recursive : bool = True) -> bool:
|
||||||
|
user_str = ""
|
||||||
|
|
||||||
|
if user == UserType.HOST_USER:
|
||||||
|
user_str = _get_user()+":"+_get_user_group()
|
||||||
|
elif user == UserType.EFFECTIVE_USER:
|
||||||
|
user_str = _get_effective_user()+":"+_get_effective_user_group()
|
||||||
|
elif user == UserType.ROOT:
|
||||||
|
user_str = "root:root"
|
||||||
|
else:
|
||||||
|
raise Exception("Unknown User Type")
|
||||||
|
|
||||||
|
result = call(["chown", "-R", user_str, path] if recursive else ["chown", user_str, path])
|
||||||
|
return result == 0
|
||||||
|
|
||||||
|
def chmod(path : str, permissions : int, recursive : bool = True) -> bool:
|
||||||
|
result = call(["chmod", "-R", str(permissions), path] if recursive else ["chmod", str(permissions), path])
|
||||||
|
return result == 0
|
||||||
|
|
||||||
|
def folder_owner(path : str) -> UserType|None:
|
||||||
|
user_owner = _get_user_owner(path)
|
||||||
|
|
||||||
|
if (user_owner == _get_user()):
|
||||||
|
return UserType.HOST_USER
|
||||||
|
|
||||||
|
elif (user_owner == _get_effective_user()):
|
||||||
|
return UserType.EFFECTIVE_USER
|
||||||
|
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_home_path(user : UserType = UserType.HOST_USER) -> str:
|
||||||
|
user_name = "root"
|
||||||
|
|
||||||
|
if user == UserType.HOST_USER:
|
||||||
|
user_name = _get_user()
|
||||||
|
elif user == UserType.EFFECTIVE_USER:
|
||||||
|
user_name = _get_effective_user()
|
||||||
|
elif user == UserType.ROOT:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise Exception("Unknown User Type")
|
||||||
|
|
||||||
|
return pwd.getpwnam(user_name).pw_dir
|
||||||
|
|
||||||
|
def get_username() -> str:
|
||||||
|
return _get_user()
|
||||||
|
|
||||||
|
def setgid(user : UserType = UserType.HOST_USER):
|
||||||
|
user_id = 0
|
||||||
|
|
||||||
|
if user == UserType.HOST_USER:
|
||||||
|
user_id = _get_user_group_id()
|
||||||
|
elif user == UserType.ROOT:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise Exception("Unknown user type")
|
||||||
|
|
||||||
|
os.setgid(user_id)
|
||||||
|
|
||||||
|
def setuid(user : UserType = UserType.HOST_USER):
|
||||||
|
user_id = 0
|
||||||
|
|
||||||
|
if user == UserType.HOST_USER:
|
||||||
|
user_id = _get_user_id()
|
||||||
|
elif user == UserType.ROOT:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise Exception("Unknown user type")
|
||||||
|
|
||||||
|
os.setuid(user_id)
|
||||||
|
|
||||||
|
async def service_active(service_name : str) -> bool:
|
||||||
|
res = run(["systemctl", "is-active", service_name], stdout=DEVNULL, stderr=DEVNULL)
|
||||||
|
return res.returncode == 0
|
||||||
|
|
||||||
|
async def service_restart(service_name : str) -> bool:
|
||||||
|
call(["systemctl", "daemon-reload"])
|
||||||
|
cmd = ["systemctl", "restart", service_name]
|
||||||
|
res = run(cmd, stdout=PIPE, stderr=STDOUT)
|
||||||
|
return res.returncode == 0
|
||||||
|
|
||||||
|
async def service_stop(service_name : str) -> bool:
|
||||||
|
cmd = ["systemctl", "stop", service_name]
|
||||||
|
res = run(cmd, stdout=PIPE, stderr=STDOUT)
|
||||||
|
return res.returncode == 0
|
||||||
|
|
||||||
|
async def service_start(service_name : str) -> bool:
|
||||||
|
cmd = ["systemctl", "start", service_name]
|
||||||
|
res = run(cmd, stdout=PIPE, stderr=STDOUT)
|
||||||
|
return res.returncode == 0
|
||||||
|
|
||||||
|
def get_privileged_path() -> str:
|
||||||
|
path = os.getenv("PRIVILEGED_PATH")
|
||||||
|
|
||||||
|
if path == None:
|
||||||
|
path = get_unprivileged_path()
|
||||||
|
|
||||||
|
return path
|
||||||
|
|
||||||
|
def _parent_dir(path : str) -> str:
|
||||||
|
if path == None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if path.endswith('/'):
|
||||||
|
path = path[:-1]
|
||||||
|
|
||||||
|
return os.path.dirname(path)
|
||||||
|
|
||||||
|
def get_unprivileged_path() -> str:
|
||||||
|
path = os.getenv("UNPRIVILEGED_PATH")
|
||||||
|
|
||||||
|
if path == None:
|
||||||
|
path = _parent_dir(os.getenv("PLUGIN_PATH"))
|
||||||
|
|
||||||
|
if path == None:
|
||||||
|
logger.debug("Unprivileged path is not properly configured. Making something up!")
|
||||||
|
# Expected path of loader binary is /home/deck/homebrew/service/PluginLoader
|
||||||
|
path = _parent_dir(_parent_dir(os.path.realpath(sys.argv[0])))
|
||||||
|
|
||||||
|
if not os.path.exists(path):
|
||||||
|
path = None
|
||||||
|
|
||||||
|
if path == None:
|
||||||
|
logger.warn("Unprivileged path is not properly configured. Defaulting to /home/deck/homebrew")
|
||||||
|
path = "/home/deck/homebrew" # We give up
|
||||||
|
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def get_unprivileged_user() -> str:
|
||||||
|
user = os.getenv("UNPRIVILEGED_USER")
|
||||||
|
|
||||||
|
if user == None:
|
||||||
|
# Lets hope we can extract it from the unprivileged dir
|
||||||
|
dir = os.path.realpath(get_unprivileged_path())
|
||||||
|
|
||||||
|
pws = sorted(pwd.getpwall(), reverse=True, key=lambda pw: len(pw.pw_dir))
|
||||||
|
for pw in pws:
|
||||||
|
if dir.startswith(os.path.realpath(pw.pw_dir)):
|
||||||
|
user = pw.pw_name
|
||||||
|
break
|
||||||
|
|
||||||
|
if user == None:
|
||||||
|
logger.warn("Unprivileged user is not properly configured. Defaulting to 'deck'")
|
||||||
|
user = 'deck'
|
||||||
|
|
||||||
|
return user
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
from customtypes import UserType
|
||||||
|
import os, sys
|
||||||
|
|
||||||
|
def chown(path : str, user : UserType = UserType.HOST_USER, recursive : bool = True) -> bool:
|
||||||
|
return True # Stubbed
|
||||||
|
|
||||||
|
def chmod(path : str, permissions : int, recursive : bool = True) -> bool:
|
||||||
|
return True # Stubbed
|
||||||
|
|
||||||
|
def folder_owner(path : str) -> UserType|None:
|
||||||
|
return UserType.HOST_USER # Stubbed
|
||||||
|
|
||||||
|
def get_home_path(user : UserType = UserType.HOST_USER) -> str:
|
||||||
|
return os.path.expanduser("~") # Mostly stubbed
|
||||||
|
|
||||||
|
def setgid(user : UserType = UserType.HOST_USER):
|
||||||
|
pass # Stubbed
|
||||||
|
|
||||||
|
def setuid(user : UserType = UserType.HOST_USER):
|
||||||
|
pass # Stubbed
|
||||||
|
|
||||||
|
async def service_active(service_name : str) -> bool:
|
||||||
|
return True # Stubbed
|
||||||
|
|
||||||
|
async def service_stop(service_name : str) -> bool:
|
||||||
|
return True # Stubbed
|
||||||
|
|
||||||
|
async def service_start(service_name : str) -> bool:
|
||||||
|
return True # Stubbed
|
||||||
|
|
||||||
|
async def service_restart(service_name : str) -> bool:
|
||||||
|
if service_name == "plugin_loader":
|
||||||
|
sys.exit(42)
|
||||||
|
|
||||||
|
return True # Stubbed
|
||||||
|
|
||||||
|
def get_username() -> str:
|
||||||
|
return os.getlogin()
|
||||||
|
|
||||||
|
def get_privileged_path() -> str:
|
||||||
|
'''On windows, privileged_path is equal to unprivileged_path'''
|
||||||
|
return get_unprivileged_path()
|
||||||
|
|
||||||
|
def get_unprivileged_path() -> str:
|
||||||
|
path = os.getenv("UNPRIVILEGED_PATH")
|
||||||
|
|
||||||
|
if path == None:
|
||||||
|
path = os.getenv("PRIVILEGED_PATH", os.path.join(os.path.expanduser("~"), "homebrew"))
|
||||||
|
|
||||||
|
return path
|
||||||
|
|
||||||
|
def get_unprivileged_user() -> str:
|
||||||
|
return os.getenv("UNPRIVILEGED_USER", os.getlogin())
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import asyncio, time, random
|
||||||
|
from localplatform import ON_WINDOWS
|
||||||
|
|
||||||
|
BUFFER_LIMIT = 2 ** 20 # 1 MiB
|
||||||
|
|
||||||
|
class UnixSocket:
|
||||||
|
def __init__(self, on_new_message):
|
||||||
|
'''
|
||||||
|
on_new_message takes 1 string argument.
|
||||||
|
It's return value gets used, if not None, to write data to the socket.
|
||||||
|
Method should be async
|
||||||
|
'''
|
||||||
|
self.socket_addr = f"/tmp/plugin_socket_{time.time()}"
|
||||||
|
self.on_new_message = on_new_message
|
||||||
|
self.socket = None
|
||||||
|
self.reader = None
|
||||||
|
self.writer = None
|
||||||
|
|
||||||
|
async def setup_server(self):
|
||||||
|
self.socket = await asyncio.start_unix_server(self._listen_for_method_call, path=self.socket_addr, limit=BUFFER_LIMIT)
|
||||||
|
|
||||||
|
async def _open_socket_if_not_exists(self):
|
||||||
|
if not self.reader:
|
||||||
|
retries = 0
|
||||||
|
while retries < 10:
|
||||||
|
try:
|
||||||
|
self.reader, self.writer = await asyncio.open_unix_connection(self.socket_addr, limit=BUFFER_LIMIT)
|
||||||
|
return True
|
||||||
|
except:
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
retries += 1
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def get_socket_connection(self):
|
||||||
|
if not await self._open_socket_if_not_exists():
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
return self.reader, self.writer
|
||||||
|
|
||||||
|
async def close_socket_connection(self):
|
||||||
|
if self.writer != None:
|
||||||
|
self.writer.close()
|
||||||
|
|
||||||
|
self.reader = None
|
||||||
|
|
||||||
|
async def read_single_line(self) -> str|None:
|
||||||
|
reader, writer = await self.get_socket_connection()
|
||||||
|
|
||||||
|
if self.reader == None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return await self._read_single_line(reader)
|
||||||
|
|
||||||
|
async def write_single_line(self, message : str):
|
||||||
|
reader, writer = await self.get_socket_connection()
|
||||||
|
|
||||||
|
if self.writer == None:
|
||||||
|
return;
|
||||||
|
|
||||||
|
await self._write_single_line(writer, message)
|
||||||
|
|
||||||
|
async def _read_single_line(self, reader) -> str:
|
||||||
|
line = bytearray()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
line.extend(await reader.readuntil())
|
||||||
|
except asyncio.LimitOverrunError:
|
||||||
|
line.extend(await reader.read(reader._limit))
|
||||||
|
continue
|
||||||
|
except asyncio.IncompleteReadError as err:
|
||||||
|
line.extend(err.partial)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
return line.decode("utf-8")
|
||||||
|
|
||||||
|
async def _write_single_line(self, writer, message : str):
|
||||||
|
if not message.endswith("\n"):
|
||||||
|
message += "\n"
|
||||||
|
|
||||||
|
writer.write(message.encode("utf-8"))
|
||||||
|
await writer.drain()
|
||||||
|
|
||||||
|
async def _listen_for_method_call(self, reader, writer):
|
||||||
|
while True:
|
||||||
|
line = await self._read_single_line(reader)
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = await self.on_new_message(line)
|
||||||
|
except Exception as e:
|
||||||
|
return
|
||||||
|
|
||||||
|
if res != None:
|
||||||
|
await self._write_single_line(writer, res)
|
||||||
|
|
||||||
|
class PortSocket (UnixSocket):
|
||||||
|
def __init__(self, on_new_message):
|
||||||
|
'''
|
||||||
|
on_new_message takes 1 string argument.
|
||||||
|
It's return value gets used, if not None, to write data to the socket.
|
||||||
|
Method should be async
|
||||||
|
'''
|
||||||
|
super().__init__(on_new_message)
|
||||||
|
self.host = "127.0.0.1"
|
||||||
|
self.port = random.sample(range(40000, 60000), 1)[0]
|
||||||
|
|
||||||
|
async def setup_server(self):
|
||||||
|
self.socket = await asyncio.start_server(self._listen_for_method_call, host=self.host, port=self.port, limit=BUFFER_LIMIT)
|
||||||
|
|
||||||
|
async def _open_socket_if_not_exists(self):
|
||||||
|
if not self.reader:
|
||||||
|
retries = 0
|
||||||
|
while retries < 10:
|
||||||
|
try:
|
||||||
|
self.reader, self.writer = await asyncio.open_connection(host=self.host, port=self.port, limit=BUFFER_LIMIT)
|
||||||
|
return True
|
||||||
|
except:
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
retries += 1
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if ON_WINDOWS:
|
||||||
|
class LocalSocket (PortSocket):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
class LocalSocket (UnixSocket):
|
||||||
|
pass
|
||||||
+51
-51
@@ -1,14 +1,19 @@
|
|||||||
# Change PyInstaller files permissions
|
# Change PyInstaller files permissions
|
||||||
import sys
|
import sys
|
||||||
from subprocess import call
|
from localplatform import (chmod, chown, service_stop, service_start,
|
||||||
|
ON_WINDOWS, get_log_level, get_live_reload,
|
||||||
|
get_server_port, get_server_host, get_chown_plugin_path,
|
||||||
|
get_unprivileged_user, get_unprivileged_path,
|
||||||
|
get_privileged_path)
|
||||||
if hasattr(sys, '_MEIPASS'):
|
if hasattr(sys, '_MEIPASS'):
|
||||||
call(['chmod', '-R', '755', sys._MEIPASS])
|
chmod(sys._MEIPASS, 755)
|
||||||
# Full imports
|
# Full imports
|
||||||
from asyncio import new_event_loop, set_event_loop, sleep
|
from asyncio import new_event_loop, set_event_loop, sleep
|
||||||
from json import dumps, loads
|
from json import dumps, loads
|
||||||
from logging import DEBUG, INFO, basicConfig, getLogger
|
from logging import DEBUG, INFO, basicConfig, getLogger
|
||||||
from os import getenv, chmod, path
|
from os import getenv, path
|
||||||
from traceback import format_exc
|
from traceback import format_exc
|
||||||
|
import multiprocessing
|
||||||
|
|
||||||
import aiohttp_cors
|
import aiohttp_cors
|
||||||
# Partial imports
|
# Partial imports
|
||||||
@@ -19,48 +24,33 @@ from aiohttp_jinja2 import setup as jinja_setup
|
|||||||
# local modules
|
# local modules
|
||||||
from browser import PluginBrowser
|
from browser import PluginBrowser
|
||||||
from helpers import (REMOTE_DEBUGGER_UNIT, csrf_middleware, get_csrf_token,
|
from helpers import (REMOTE_DEBUGGER_UNIT, csrf_middleware, get_csrf_token,
|
||||||
get_home_path, get_homebrew_path, get_user,
|
mkdir_as_user, get_system_pythonpaths)
|
||||||
get_user_group, set_user, set_user_group,
|
|
||||||
stop_systemd_unit, start_systemd_unit)
|
from injector import get_gamepadui_tab, Tab, get_tabs, close_old_tabs
|
||||||
from injector import get_gamepadui_tab, Tab, get_tabs
|
|
||||||
from loader import Loader
|
from loader import Loader
|
||||||
from settings import SettingsManager
|
from settings import SettingsManager
|
||||||
from updater import Updater
|
from updater import Updater
|
||||||
from utilities import Utilities
|
from utilities import Utilities
|
||||||
|
from customtypes import UserType
|
||||||
|
|
||||||
# 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", 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")
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
basicConfig(
|
basicConfig(
|
||||||
level=CONFIG["log_level"],
|
level=get_log_level(),
|
||||||
format="[%(module)s][%(levelname)s]: %(message)s"
|
format="[%(module)s][%(levelname)s]: %(message)s"
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = getLogger("Main")
|
logger = getLogger("Main")
|
||||||
|
plugin_path = path.join(get_privileged_path(), "plugins")
|
||||||
|
|
||||||
async def chown_plugin_dir():
|
def chown_plugin_dir():
|
||||||
code_chown = call(["chown", "-R", USER+":"+GROUP, CONFIG["plugin_path"]])
|
if not path.exists(plugin_path): # For safety, create the folder before attempting to do anything with it
|
||||||
code_chmod = call(["chmod", "-R", "555", CONFIG["plugin_path"]])
|
mkdir_as_user(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})")
|
if not chown(plugin_path, UserType.HOST_USER) or not chmod(plugin_path, 555):
|
||||||
|
logger.error(f"chown/chmod exited with a non-zero exit code")
|
||||||
|
|
||||||
|
if get_chown_plugin_path() == True:
|
||||||
|
chown_plugin_dir()
|
||||||
|
|
||||||
class PluginManager:
|
class PluginManager:
|
||||||
def __init__(self, loop) -> None:
|
def __init__(self, loop) -> None:
|
||||||
@@ -74,9 +64,9 @@ class PluginManager:
|
|||||||
allow_credentials=True
|
allow_credentials=True
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
self.plugin_loader = Loader(self.web_app, CONFIG["plugin_path"], self.loop, CONFIG["live_reload"])
|
self.plugin_loader = Loader(self.web_app, plugin_path, self.loop, get_live_reload())
|
||||||
self.plugin_browser = PluginBrowser(CONFIG["plugin_path"], self.plugin_loader.plugins, self.plugin_loader)
|
self.settings = SettingsManager("loader", path.join(get_privileged_path(), "settings"))
|
||||||
self.settings = SettingsManager("loader", path.join(HOMEBREW_PATH, "settings"))
|
self.plugin_browser = PluginBrowser(plugin_path, self.plugin_loader.plugins, self.plugin_loader, self.settings)
|
||||||
self.utilities = Utilities(self)
|
self.utilities = Utilities(self)
|
||||||
self.updater = Updater(self)
|
self.updater = Updater(self)
|
||||||
|
|
||||||
@@ -84,11 +74,9 @@ class PluginManager:
|
|||||||
|
|
||||||
async def startup(_):
|
async def startup(_):
|
||||||
if self.settings.getSetting("cef_forward", False):
|
if self.settings.getSetting("cef_forward", False):
|
||||||
self.loop.create_task(start_systemd_unit(REMOTE_DEBUGGER_UNIT))
|
self.loop.create_task(service_start(REMOTE_DEBUGGER_UNIT))
|
||||||
else:
|
else:
|
||||||
self.loop.create_task(stop_systemd_unit(REMOTE_DEBUGGER_UNIT))
|
self.loop.create_task(service_stop(REMOTE_DEBUGGER_UNIT))
|
||||||
if CONFIG["chown_plugin_path"] == True:
|
|
||||||
chown_plugin_dir()
|
|
||||||
self.loop.create_task(self.loader_reinjector())
|
self.loop.create_task(self.loader_reinjector())
|
||||||
self.loop.create_task(self.load_plugins())
|
self.loop.create_task(self.load_plugins())
|
||||||
|
|
||||||
@@ -115,6 +103,9 @@ class PluginManager:
|
|||||||
logger.debug("Loading plugins")
|
logger.debug("Loading plugins")
|
||||||
self.plugin_loader.import_plugins()
|
self.plugin_loader.import_plugins()
|
||||||
# await inject_to_tab("SP", "window.syncDeckyPlugins();")
|
# await inject_to_tab("SP", "window.syncDeckyPlugins();")
|
||||||
|
if self.settings.getSetting("pluginOrder", None) == None:
|
||||||
|
self.settings.setSetting("pluginOrder", list(self.plugin_loader.plugins.keys()))
|
||||||
|
logger.debug("Did not find pluginOrder setting, set it to default")
|
||||||
|
|
||||||
async def loader_reinjector(self):
|
async def loader_reinjector(self):
|
||||||
while True:
|
while True:
|
||||||
@@ -124,7 +115,7 @@ class PluginManager:
|
|||||||
while not tab:
|
while not tab:
|
||||||
try:
|
try:
|
||||||
tab = await get_gamepadui_tab()
|
tab = await get_gamepadui_tab()
|
||||||
except client_exceptions.ClientConnectorError or client_exceptions.ServerDisconnectedError:
|
except (client_exceptions.ClientConnectorError, client_exceptions.ServerDisconnectedError):
|
||||||
if not dc:
|
if not dc:
|
||||||
logger.debug("Couldn't connect to debugger, waiting...")
|
logger.debug("Couldn't connect to debugger, waiting...")
|
||||||
dc = True
|
dc = True
|
||||||
@@ -168,23 +159,32 @@ class PluginManager:
|
|||||||
async def inject_javascript(self, tab: Tab, first=False, request=None):
|
async def inject_javascript(self, tab: Tab, first=False, request=None):
|
||||||
logger.info("Loading Decky frontend!")
|
logger.info("Loading Decky frontend!")
|
||||||
try:
|
try:
|
||||||
# if first:
|
if first:
|
||||||
# if await tab.has_global_var("deckyHasLoaded", False):
|
if await tab.has_global_var("deckyHasLoaded", False):
|
||||||
# tabs = await get_tabs()
|
await close_old_tabs()
|
||||||
# for t in tabs:
|
await tab.evaluate_js("try{if (window.deckyHasLoaded){setTimeout(() => location.reload(), 100)}else{window.deckyHasLoaded = true;(async()=>{try{while(!window.SP_REACT){await new Promise(r => setTimeout(r, 10))};await import('http://localhost:1337/frontend/index.js')}catch(e){console.error(e)};})();}}catch(e){console.error(e)}", False, False, False)
|
||||||
# if t.title != "Steam" and t.title != "SP":
|
|
||||||
# logger.debug("Closing tab: " + getattr(t, "title", "Untitled"))
|
|
||||||
# await t.close()
|
|
||||||
# await sleep(0.5)
|
|
||||||
await tab.evaluate_js("try{if (window.deckyHasLoaded){setTimeout(() => SteamClient.User.StartRestart(), 100)}else{window.deckyHasLoaded = true;(async()=>{try{while(!window.SP_REACT){await new Promise(r => setTimeout(r, 10))};await import('http://localhost:1337/frontend/index.js')}catch(e){console.error(e)};})();}}catch(e){console.error(e)}", False, False, False)
|
|
||||||
except:
|
except:
|
||||||
logger.info("Failed to inject JavaScript into tab\n" + format_exc())
|
logger.info("Failed to inject JavaScript into tab\n" + format_exc())
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
return run_app(self.web_app, host=CONFIG["server_host"], port=CONFIG["server_port"], loop=self.loop, access_log=None)
|
return run_app(self.web_app, host=get_server_host(), port=get_server_port(), loop=self.loop, access_log=None)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
if ON_WINDOWS:
|
||||||
|
# Fix windows/flask not recognising that .js means 'application/javascript'
|
||||||
|
import mimetypes
|
||||||
|
mimetypes.add_type('application/javascript', '.js')
|
||||||
|
|
||||||
|
# Required for multiprocessing support in frozen files
|
||||||
|
multiprocessing.freeze_support()
|
||||||
|
|
||||||
|
# Append the loader's plugin path to the recognized python paths
|
||||||
|
sys.path.append(path.join(path.dirname(__file__), "plugin"))
|
||||||
|
|
||||||
|
# Append the system and user python paths
|
||||||
|
sys.path.extend(get_system_pythonpaths())
|
||||||
|
|
||||||
loop = new_event_loop()
|
loop = new_event_loop()
|
||||||
set_event_loop(loop)
|
set_event_loop(loop)
|
||||||
PluginManager(loop).run()
|
PluginManager(loop).run()
|
||||||
|
|||||||
+71
-86
@@ -1,38 +1,35 @@
|
|||||||
import multiprocessing
|
import multiprocessing
|
||||||
from asyncio import (Lock, get_event_loop, new_event_loop,
|
from asyncio import (Lock, get_event_loop, new_event_loop,
|
||||||
open_unix_connection, set_event_loop, sleep,
|
set_event_loop, sleep)
|
||||||
start_unix_server, IncompleteReadError, LimitOverrunError)
|
|
||||||
from concurrent.futures import ProcessPoolExecutor
|
from concurrent.futures import ProcessPoolExecutor
|
||||||
from importlib.util import module_from_spec, spec_from_file_location
|
from importlib.util import module_from_spec, spec_from_file_location
|
||||||
from json import dumps, load, loads
|
from json import dumps, load, loads
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from traceback import format_exc
|
from traceback import format_exc
|
||||||
from os import path, setgid, setuid
|
from os import path, environ
|
||||||
from signal import SIGINT, signal
|
from signal import SIGINT, signal
|
||||||
from sys import exit
|
from sys import exit, path as syspath
|
||||||
from time import time
|
from time import time
|
||||||
|
from localsocket import LocalSocket
|
||||||
multiprocessing.set_start_method("fork")
|
from localplatform import setgid, setuid, get_username, get_home_path
|
||||||
|
from customtypes import UserType
|
||||||
BUFFER_LIMIT = 2 ** 20 # 1 MiB
|
import helpers
|
||||||
|
|
||||||
class PluginWrapper:
|
class PluginWrapper:
|
||||||
def __init__(self, file, plugin_directory, plugin_path) -> None:
|
def __init__(self, file, plugin_directory, plugin_path) -> None:
|
||||||
self.file = file
|
self.file = file
|
||||||
|
self.plugin_path = plugin_path
|
||||||
self.plugin_directory = plugin_directory
|
self.plugin_directory = plugin_directory
|
||||||
self.reader = None
|
|
||||||
self.writer = None
|
|
||||||
self.socket_addr = f"/tmp/plugin_socket_{time()}"
|
|
||||||
self.method_call_lock = Lock()
|
self.method_call_lock = Lock()
|
||||||
|
self.socket = LocalSocket(self._on_new_message)
|
||||||
|
|
||||||
self.version = None
|
self.version = None
|
||||||
|
|
||||||
json = load(open(path.join(plugin_path, plugin_directory, "plugin.json"), "r"))
|
json = load(open(path.join(plugin_path, plugin_directory, "plugin.json"), "r", encoding="utf-8"))
|
||||||
if path.isfile(path.join(plugin_path, plugin_directory, "package.json")):
|
if path.isfile(path.join(plugin_path, plugin_directory, "package.json")):
|
||||||
package_json = load(open(path.join(plugin_path, plugin_directory, "package.json"), "r"))
|
package_json = load(open(path.join(plugin_path, plugin_directory, "package.json"), "r", encoding="utf-8"))
|
||||||
self.version = package_json["version"]
|
self.version = package_json["version"]
|
||||||
|
|
||||||
|
|
||||||
self.legacy = False
|
self.legacy = False
|
||||||
self.main_view_html = json["main_view_html"] if "main_view_html" in json else ""
|
self.main_view_html = json["main_view_html"] if "main_view_html" in json else ""
|
||||||
self.tile_view_html = json["tile_view_html"] if "tile_view_html" in json else ""
|
self.tile_view_html = json["tile_view_html"] if "tile_view_html" in json else ""
|
||||||
@@ -56,16 +53,39 @@ class PluginWrapper:
|
|||||||
set_event_loop(new_event_loop())
|
set_event_loop(new_event_loop())
|
||||||
if self.passive:
|
if self.passive:
|
||||||
return
|
return
|
||||||
setgid(0 if "root" in self.flags else 1000)
|
setgid(UserType.ROOT if "root" in self.flags else UserType.HOST_USER)
|
||||||
setuid(0 if "root" in self.flags else 1000)
|
setuid(UserType.ROOT if "root" in self.flags else UserType.HOST_USER)
|
||||||
|
# export a bunch of environment variables to help plugin developers
|
||||||
|
environ["HOME"] = get_home_path(UserType.ROOT if "root" in self.flags else UserType.HOST_USER)
|
||||||
|
environ["USER"] = "root" if "root" in self.flags else get_username()
|
||||||
|
environ["DECKY_VERSION"] = helpers.get_loader_version()
|
||||||
|
environ["DECKY_USER"] = get_username()
|
||||||
|
environ["DECKY_USER_HOME"] = helpers.get_home_path()
|
||||||
|
environ["DECKY_HOME"] = helpers.get_homebrew_path()
|
||||||
|
environ["DECKY_PLUGIN_SETTINGS_DIR"] = path.join(environ["DECKY_HOME"], "settings", self.plugin_directory)
|
||||||
|
helpers.mkdir_as_user(environ["DECKY_PLUGIN_SETTINGS_DIR"])
|
||||||
|
environ["DECKY_PLUGIN_RUNTIME_DIR"] = path.join(environ["DECKY_HOME"], "data", self.plugin_directory)
|
||||||
|
helpers.mkdir_as_user(environ["DECKY_PLUGIN_RUNTIME_DIR"])
|
||||||
|
environ["DECKY_PLUGIN_LOG_DIR"] = path.join(environ["DECKY_HOME"], "logs", self.plugin_directory)
|
||||||
|
helpers.mkdir_as_user(environ["DECKY_PLUGIN_LOG_DIR"])
|
||||||
|
environ["DECKY_PLUGIN_DIR"] = path.join(self.plugin_path, self.plugin_directory)
|
||||||
|
environ["DECKY_PLUGIN_NAME"] = self.name
|
||||||
|
environ["DECKY_PLUGIN_VERSION"] = self.version
|
||||||
|
environ["DECKY_PLUGIN_AUTHOR"] = self.author
|
||||||
|
|
||||||
|
# append the plugin's `py_modules` to the recognized python paths
|
||||||
|
syspath.append(path.join(environ["DECKY_PLUGIN_DIR"], "py_modules"))
|
||||||
|
|
||||||
spec = spec_from_file_location("_", self.file)
|
spec = spec_from_file_location("_", self.file)
|
||||||
module = module_from_spec(spec)
|
module = module_from_spec(spec)
|
||||||
spec.loader.exec_module(module)
|
spec.loader.exec_module(module)
|
||||||
self.Plugin = module.Plugin
|
self.Plugin = module.Plugin
|
||||||
|
|
||||||
|
if hasattr(self.Plugin, "_migration"):
|
||||||
|
get_event_loop().run_until_complete(self.Plugin._migration(self.Plugin))
|
||||||
if hasattr(self.Plugin, "_main"):
|
if hasattr(self.Plugin, "_main"):
|
||||||
get_event_loop().create_task(self.Plugin._main(self.Plugin))
|
get_event_loop().create_task(self.Plugin._main(self.Plugin))
|
||||||
get_event_loop().create_task(self._setup_socket())
|
get_event_loop().create_task(self.socket.setup_server())
|
||||||
get_event_loop().run_forever()
|
get_event_loop().run_forever()
|
||||||
except:
|
except:
|
||||||
self.log.error("Failed to start " + self.name + "!\n" + format_exc())
|
self.log.error("Failed to start " + self.name + "!\n" + format_exc())
|
||||||
@@ -73,61 +93,36 @@ class PluginWrapper:
|
|||||||
|
|
||||||
async def _unload(self):
|
async def _unload(self):
|
||||||
try:
|
try:
|
||||||
self.log.info("Attempting to unload " + self.name + "\n")
|
self.log.info("Attempting to unload with plugin " + self.name + "'s \"_unload\" function.\n")
|
||||||
if hasattr(self.Plugin, "_unload"):
|
if hasattr(self.Plugin, "_unload"):
|
||||||
await self.Plugin._unload(self.Plugin)
|
await self.Plugin._unload(self.Plugin)
|
||||||
|
self.log.info("Unloaded " + self.name + "\n")
|
||||||
|
else:
|
||||||
|
self.log.info("Could not find \"_unload\" in " + self.name + "'s main.py" + "\n")
|
||||||
except:
|
except:
|
||||||
self.log.error("Failed to unload " + self.name + "!\n" + format_exc())
|
self.log.error("Failed to unload " + self.name + "!\n" + format_exc())
|
||||||
exit(0)
|
exit(0)
|
||||||
|
|
||||||
async def _setup_socket(self):
|
async def _on_new_message(self, message : str) -> str|None:
|
||||||
self.socket = await start_unix_server(self._listen_for_method_call, path=self.socket_addr, limit=BUFFER_LIMIT)
|
data = loads(message)
|
||||||
|
|
||||||
async def _listen_for_method_call(self, reader, writer):
|
if "stop" in data:
|
||||||
while True:
|
self.log.info("Calling Loader unload function.")
|
||||||
line = bytearray()
|
await self._unload()
|
||||||
while True:
|
get_event_loop().stop()
|
||||||
try:
|
while get_event_loop().is_running():
|
||||||
line.extend(await reader.readuntil())
|
await sleep(0)
|
||||||
except LimitOverrunError:
|
get_event_loop().close()
|
||||||
line.extend(await reader.read(reader._limit))
|
raise Exception("Closing message listener")
|
||||||
continue
|
|
||||||
except IncompleteReadError as err:
|
|
||||||
line.extend(err.partial)
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
data = loads(line.decode("utf-8"))
|
|
||||||
if "stop" in data:
|
|
||||||
await self._unload()
|
|
||||||
get_event_loop().stop()
|
|
||||||
while get_event_loop().is_running():
|
|
||||||
await sleep(0)
|
|
||||||
get_event_loop().close()
|
|
||||||
return
|
|
||||||
d = {"res": None, "success": True}
|
|
||||||
try:
|
|
||||||
d["res"] = await getattr(self.Plugin, data["method"])(self.Plugin, **data["args"])
|
|
||||||
except Exception as e:
|
|
||||||
d["res"] = str(e)
|
|
||||||
d["success"] = False
|
|
||||||
finally:
|
|
||||||
writer.write((dumps(d)+"\n").encode("utf-8"))
|
|
||||||
await writer.drain()
|
|
||||||
|
|
||||||
async def _open_socket_if_not_exists(self):
|
d = {"res": None, "success": True}
|
||||||
if not self.reader:
|
try:
|
||||||
retries = 0
|
d["res"] = await getattr(self.Plugin, data["method"])(self.Plugin, **data["args"])
|
||||||
while retries < 10:
|
except Exception as e:
|
||||||
try:
|
d["res"] = str(e)
|
||||||
self.reader, self.writer = await open_unix_connection(self.socket_addr, limit=BUFFER_LIMIT)
|
d["success"] = False
|
||||||
return True
|
finally:
|
||||||
except:
|
return dumps(d, ensure_ascii=False)
|
||||||
await sleep(2)
|
|
||||||
retries += 1
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
return True
|
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
if self.passive:
|
if self.passive:
|
||||||
@@ -138,34 +133,24 @@ class PluginWrapper:
|
|||||||
def stop(self):
|
def stop(self):
|
||||||
if self.passive:
|
if self.passive:
|
||||||
return
|
return
|
||||||
|
|
||||||
async def _(self):
|
async def _(self):
|
||||||
if await self._open_socket_if_not_exists():
|
await self.socket.write_single_line(dumps({ "stop": True }, ensure_ascii=False))
|
||||||
self.writer.write((dumps({"stop": True})+"\n").encode("utf-8"))
|
await self.socket.close_socket_connection()
|
||||||
await self.writer.drain()
|
|
||||||
self.writer.close()
|
|
||||||
get_event_loop().create_task(_(self))
|
get_event_loop().create_task(_(self))
|
||||||
|
|
||||||
async def execute_method(self, method_name, kwargs):
|
async def execute_method(self, method_name, kwargs):
|
||||||
if self.passive:
|
if self.passive:
|
||||||
raise RuntimeError("This plugin is passive (aka does not implement main.py)")
|
raise RuntimeError("This plugin is passive (aka does not implement main.py)")
|
||||||
async with self.method_call_lock:
|
async with self.method_call_lock:
|
||||||
if await self._open_socket_if_not_exists():
|
reader, writer = await self.socket.get_socket_connection()
|
||||||
self.writer.write(
|
|
||||||
(dumps({"method": method_name, "args": kwargs})+"\n").encode("utf-8"))
|
await self.socket.write_single_line(dumps({ "method": method_name, "args": kwargs }, ensure_ascii=False))
|
||||||
await self.writer.drain()
|
|
||||||
line = bytearray()
|
line = await self.socket.read_single_line()
|
||||||
while True:
|
if line != None:
|
||||||
try:
|
res = loads(line)
|
||||||
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"]:
|
if not res["success"]:
|
||||||
raise Exception(res["res"])
|
raise Exception(res["res"])
|
||||||
return res["res"]
|
return res["res"]
|
||||||
+26
-11
@@ -1,42 +1,57 @@
|
|||||||
import imp
|
|
||||||
from json import dump, load
|
from json import dump, load
|
||||||
from os import mkdir, path
|
from os import mkdir, path, listdir, rename
|
||||||
|
from localplatform import chown, folder_owner, get_chown_plugin_path
|
||||||
|
from customtypes import UserType
|
||||||
|
|
||||||
from helpers import get_home_path, get_homebrew_path, get_user, set_user
|
from helpers import get_homebrew_path
|
||||||
|
|
||||||
|
|
||||||
class SettingsManager:
|
class SettingsManager:
|
||||||
def __init__(self, name, settings_directory = None) -> None:
|
def __init__(self, name, settings_directory = None) -> None:
|
||||||
set_user()
|
wrong_dir = get_homebrew_path()
|
||||||
USER = get_user()
|
|
||||||
if settings_directory == None:
|
if settings_directory == None:
|
||||||
settings_directory = get_homebrew_path(get_home_path(USER))
|
settings_directory = path.join(wrong_dir, "settings")
|
||||||
|
|
||||||
self.path = path.join(settings_directory, name + ".json")
|
self.path = path.join(settings_directory, name + ".json")
|
||||||
|
|
||||||
|
#Create the folder with the correct permission
|
||||||
if not path.exists(settings_directory):
|
if not path.exists(settings_directory):
|
||||||
mkdir(settings_directory)
|
mkdir(settings_directory)
|
||||||
|
|
||||||
|
#Copy all old settings file in the root directory to the correct folder
|
||||||
|
for file in listdir(wrong_dir):
|
||||||
|
if file.endswith(".json"):
|
||||||
|
rename(path.join(wrong_dir,file),
|
||||||
|
path.join(settings_directory, file))
|
||||||
|
self.path = path.join(settings_directory, name + ".json")
|
||||||
|
|
||||||
|
|
||||||
|
#If the owner of the settings directory is not the user, then set it as the user:
|
||||||
|
expected_user = UserType.HOST_USER if get_chown_plugin_path() else UserType.ROOT
|
||||||
|
if folder_owner(settings_directory) != expected_user:
|
||||||
|
chown(settings_directory, expected_user, False)
|
||||||
|
|
||||||
self.settings = {}
|
self.settings = {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
open(self.path, "x")
|
open(self.path, "x", encoding="utf-8")
|
||||||
except FileExistsError as e:
|
except FileExistsError as e:
|
||||||
self.read()
|
self.read()
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def read(self):
|
def read(self):
|
||||||
try:
|
try:
|
||||||
with open(self.path, "r") as file:
|
with open(self.path, "r", encoding="utf-8") as file:
|
||||||
self.settings = load(file)
|
self.settings = load(file)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
print(e)
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def commit(self):
|
def commit(self):
|
||||||
with open(self.path, "w+") as file:
|
with open(self.path, "w+", encoding="utf-8") as file:
|
||||||
dump(self.settings, file, indent=4)
|
dump(self.settings, file, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
def getSetting(self, key, default):
|
def getSetting(self, key, default=None):
|
||||||
return self.settings.get(key, default)
|
return self.settings.get(key, default)
|
||||||
|
|
||||||
def setSetting(self, key, value):
|
def setSetting(self, key, value):
|
||||||
|
|||||||
+64
-53
@@ -6,7 +6,7 @@ from ensurepip import version
|
|||||||
from json.decoder import JSONDecodeError
|
from json.decoder import JSONDecodeError
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from os import getcwd, path, remove
|
from os import getcwd, path, remove
|
||||||
from subprocess import call
|
from localplatform import chmod, service_restart, ON_LINUX
|
||||||
|
|
||||||
from aiohttp import ClientSession, web
|
from aiohttp import ClientSession, web
|
||||||
|
|
||||||
@@ -30,12 +30,7 @@ class Updater:
|
|||||||
}
|
}
|
||||||
self.remoteVer = None
|
self.remoteVer = None
|
||||||
self.allRemoteVers = None
|
self.allRemoteVers = None
|
||||||
try:
|
self.localVer = helpers.get_loader_version()
|
||||||
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:
|
try:
|
||||||
self.currentBranch = self.get_branch(self.context.settings)
|
self.currentBranch = self.get_branch(self.context.settings)
|
||||||
@@ -70,7 +65,7 @@ class Updater:
|
|||||||
logger.debug("current branch: %i" % ver)
|
logger.debug("current branch: %i" % ver)
|
||||||
if ver == -1:
|
if ver == -1:
|
||||||
logger.info("Current branch is not set, determining branch from version...")
|
logger.info("Current branch is not set, determining branch from version...")
|
||||||
if self.localVer.startswith("v") and self.localVer.find("-pre"):
|
if self.localVer.startswith("v") and "-pre" in self.localVer:
|
||||||
logger.info("Current version determined to be pre-release")
|
logger.info("Current version determined to be pre-release")
|
||||||
return 1
|
return 1
|
||||||
else:
|
else:
|
||||||
@@ -96,15 +91,12 @@ class Updater:
|
|||||||
return str(url)
|
return str(url)
|
||||||
|
|
||||||
async def get_version(self):
|
async def get_version(self):
|
||||||
if self.localVer:
|
return {
|
||||||
return {
|
"current": self.localVer,
|
||||||
"current": self.localVer,
|
"remote": self.remoteVer,
|
||||||
"remote": self.remoteVer,
|
"all": self.allRemoteVers,
|
||||||
"all": self.allRemoteVers,
|
"updatable": self.localVer != "unknown"
|
||||||
"updatable": self.localVer != None
|
}
|
||||||
}
|
|
||||||
else:
|
|
||||||
return {"current": "unknown", "remote": self.remoteVer, "all": self.allRemoteVers, "updatable": False}
|
|
||||||
|
|
||||||
async def check_for_updates(self):
|
async def check_for_updates(self):
|
||||||
logger.debug("checking for updates")
|
logger.debug("checking for updates")
|
||||||
@@ -112,14 +104,23 @@ class Updater:
|
|||||||
async with ClientSession() as web:
|
async with ClientSession() as web:
|
||||||
async with web.request("GET", "https://api.github.com/repos/SteamDeckHomebrew/decky-loader/releases", ssl=helpers.get_ssl_context()) as res:
|
async with web.request("GET", "https://api.github.com/repos/SteamDeckHomebrew/decky-loader/releases", ssl=helpers.get_ssl_context()) as res:
|
||||||
remoteVersions = await res.json()
|
remoteVersions = await res.json()
|
||||||
|
if selectedBranch == 0:
|
||||||
|
logger.debug("release type: release")
|
||||||
|
remoteVersions = list(filter(lambda ver: ver["tag_name"].startswith("v") and not ver["prerelease"] and not ver["tag_name"].find("-pre") > 0 and ver["tag_name"], remoteVersions))
|
||||||
|
elif selectedBranch == 1:
|
||||||
|
logger.debug("release type: pre-release")
|
||||||
|
remoteVersions = list(filter(lambda ver:ver["tag_name"].startswith("v"), remoteVersions))
|
||||||
|
else:
|
||||||
|
logger.error("release type: NOT FOUND")
|
||||||
|
raise ValueError("no valid branch found")
|
||||||
self.allRemoteVers = remoteVersions
|
self.allRemoteVers = remoteVersions
|
||||||
logger.debug("determining release type to find, branch is %i" % selectedBranch)
|
logger.debug("determining release type to find, branch is %i" % selectedBranch)
|
||||||
if selectedBranch == 0:
|
if selectedBranch == 0:
|
||||||
logger.debug("release type: release")
|
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)
|
self.remoteVer = next(filter(lambda ver: ver["tag_name"].startswith("v") and not ver["prerelease"] and not ver["tag_name"].find("-pre") > 0 and ver["tag_name"], remoteVersions), None)
|
||||||
elif selectedBranch == 1:
|
elif selectedBranch == 1:
|
||||||
logger.debug("release type: pre-release")
|
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)
|
self.remoteVer = next(filter(lambda ver:ver["tag_name"].startswith("v"), remoteVersions), None)
|
||||||
else:
|
else:
|
||||||
logger.error("release type: NOT FOUND")
|
logger.error("release type: NOT FOUND")
|
||||||
raise ValueError("no valid branch found")
|
raise ValueError("no valid branch found")
|
||||||
@@ -140,47 +141,54 @@ class Updater:
|
|||||||
async def do_update(self):
|
async def do_update(self):
|
||||||
logger.debug("Starting update.")
|
logger.debug("Starting update.")
|
||||||
version = self.remoteVer["tag_name"]
|
version = self.remoteVer["tag_name"]
|
||||||
download_url = self.remoteVer["assets"][0]["browser_download_url"]
|
download_url = None
|
||||||
|
download_filename = "PluginLoader" if ON_LINUX else "PluginLoader.exe"
|
||||||
|
download_temp_filename = download_filename + ".new"
|
||||||
|
|
||||||
|
for x in self.remoteVer["assets"]:
|
||||||
|
if x["name"] == download_filename:
|
||||||
|
download_url = x["browser_download_url"]
|
||||||
|
break
|
||||||
|
|
||||||
|
if download_url == None:
|
||||||
|
raise Exception("Download url not found")
|
||||||
|
|
||||||
service_url = self.get_service_url()
|
service_url = self.get_service_url()
|
||||||
logger.debug("Retrieved service URL")
|
logger.debug("Retrieved service URL")
|
||||||
|
|
||||||
tab = await get_gamepadui_tab()
|
tab = await get_gamepadui_tab()
|
||||||
await tab.open_websocket()
|
await tab.open_websocket()
|
||||||
async with ClientSession() as web:
|
async with ClientSession() as web:
|
||||||
logger.debug("Downloading systemd service")
|
if ON_LINUX:
|
||||||
# download the relevant systemd service depending upon branch
|
logger.debug("Downloading systemd service")
|
||||||
async with web.request("GET", service_url, ssl=helpers.get_ssl_context(), allow_redirects=True) as res:
|
# download the relevant systemd service depending upon branch
|
||||||
logger.debug("Downloading service file")
|
async with web.request("GET", service_url, ssl=helpers.get_ssl_context(), allow_redirects=True) as res:
|
||||||
data = await res.content.read()
|
logger.debug("Downloading service file")
|
||||||
logger.debug(str(data))
|
data = await res.content.read()
|
||||||
service_file_path = path.join(getcwd(), "plugin_loader.service")
|
logger.debug(str(data))
|
||||||
try:
|
service_file_path = path.join(getcwd(), "plugin_loader.service")
|
||||||
with open(path.join(getcwd(), "plugin_loader.service"), "wb") as out:
|
try:
|
||||||
out.write(data)
|
with open(path.join(getcwd(), "plugin_loader.service"), "wb") as out:
|
||||||
except Exception as e:
|
out.write(data)
|
||||||
logger.error(f"Error at %s", exc_info=e)
|
except Exception as e:
|
||||||
with open(path.join(getcwd(), "plugin_loader.service"), 'r') as service_file:
|
logger.error(f"Error at %s", exc_info=e)
|
||||||
service_data = service_file.read()
|
with open(path.join(getcwd(), "plugin_loader.service"), "r", encoding="utf-8") as service_file:
|
||||||
service_data = service_data.replace("${HOMEBREW_FOLDER}", "/home/"+helpers.get_user()+"/homebrew")
|
service_data = service_file.read()
|
||||||
with open(path.join(getcwd(), "plugin_loader.service"), 'w') as service_file:
|
service_data = service_data.replace("${HOMEBREW_FOLDER}", helpers.get_homebrew_path())
|
||||||
service_file.write(service_data)
|
with open(path.join(getcwd(), "plugin_loader.service"), "w", encoding="utf-8") as service_file:
|
||||||
|
service_file.write(service_data)
|
||||||
|
|
||||||
logger.debug("Saved service file")
|
logger.debug("Saved service file")
|
||||||
logger.debug("Copying service file over current file.")
|
logger.debug("Copying service file over current file.")
|
||||||
shutil.copy(service_file_path, "/etc/systemd/system/plugin_loader.service")
|
shutil.copy(service_file_path, "/etc/systemd/system/plugin_loader.service")
|
||||||
if not os.path.exists(path.join(getcwd(), ".systemd")):
|
if not os.path.exists(path.join(getcwd(), ".systemd")):
|
||||||
os.mkdir(path.join(getcwd(), ".systemd"))
|
os.mkdir(path.join(getcwd(), ".systemd"))
|
||||||
shutil.move(service_file_path, path.join(getcwd(), ".systemd")+"/plugin_loader.service")
|
shutil.move(service_file_path, path.join(getcwd(), ".systemd")+"/plugin_loader.service")
|
||||||
|
|
||||||
logger.debug("Downloading binary")
|
logger.debug("Downloading binary")
|
||||||
async with web.request("GET", download_url, ssl=helpers.get_ssl_context(), allow_redirects=True) as res:
|
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))
|
total = int(res.headers.get('content-length', 0))
|
||||||
# we need to not delete the binary until we have downloaded the new binary!
|
with open(path.join(getcwd(), download_temp_filename), "wb") as out:
|
||||||
try:
|
|
||||||
remove(path.join(getcwd(), "PluginLoader"))
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
with open(path.join(getcwd(), "PluginLoader"), "wb") as out:
|
|
||||||
progress = 0
|
progress = 0
|
||||||
raw = 0
|
raw = 0
|
||||||
async for c in res.content.iter_chunked(512):
|
async for c in res.content.iter_chunked(512):
|
||||||
@@ -191,15 +199,18 @@ class Updater:
|
|||||||
self.context.loop.create_task(tab.evaluate_js(f"window.DeckyUpdater.updateProgress({new_progress})", False, False, False))
|
self.context.loop.create_task(tab.evaluate_js(f"window.DeckyUpdater.updateProgress({new_progress})", False, False, False))
|
||||||
progress = new_progress
|
progress = new_progress
|
||||||
|
|
||||||
with open(path.join(getcwd(), ".loader.version"), "w") as out:
|
with open(path.join(getcwd(), ".loader.version"), "w", encoding="utf-8") as out:
|
||||||
out.write(version)
|
out.write(version)
|
||||||
|
|
||||||
call(['chmod', '+x', path.join(getcwd(), "PluginLoader")])
|
if ON_LINUX:
|
||||||
|
remove(path.join(getcwd(), download_filename))
|
||||||
|
shutil.move(path.join(getcwd(), download_temp_filename), path.join(getcwd(), download_filename))
|
||||||
|
chmod(path.join(getcwd(), download_filename), 777, False)
|
||||||
|
|
||||||
logger.info("Updated loader installation.")
|
logger.info("Updated loader installation.")
|
||||||
await tab.evaluate_js("window.DeckyUpdater.finish()", False, False)
|
await tab.evaluate_js("window.DeckyUpdater.finish()", False, False)
|
||||||
await self.do_restart()
|
await self.do_restart()
|
||||||
await tab.close_websocket()
|
await tab.close_websocket()
|
||||||
|
|
||||||
async def do_restart(self):
|
async def do_restart(self):
|
||||||
call(["systemctl", "daemon-reload"])
|
await service_restart("plugin_loader")
|
||||||
call(["systemctl", "restart", "plugin_loader"])
|
|
||||||
|
|||||||
+25
-8
@@ -7,10 +7,10 @@ from asyncio import sleep, start_server, gather, open_connection
|
|||||||
from aiohttp import ClientSession, web
|
from aiohttp import ClientSession, web
|
||||||
|
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from injector import inject_to_tab, get_gamepadui_tab
|
from injector import inject_to_tab, get_gamepadui_tab, close_old_tabs
|
||||||
import helpers
|
import helpers
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from localplatform import service_stop, service_start
|
||||||
|
|
||||||
class Utilities:
|
class Utilities:
|
||||||
def __init__(self, context) -> None:
|
def __init__(self, context) -> None:
|
||||||
@@ -81,10 +81,11 @@ class Utilities:
|
|||||||
async def http_request(self, method="", url="", **kwargs):
|
async def http_request(self, method="", url="", **kwargs):
|
||||||
async with ClientSession() as web:
|
async with ClientSession() as web:
|
||||||
res = await web.request(method, url, ssl=helpers.get_ssl_context(), **kwargs)
|
res = await web.request(method, url, ssl=helpers.get_ssl_context(), **kwargs)
|
||||||
|
text = await res.text()
|
||||||
return {
|
return {
|
||||||
"status": res.status,
|
"status": res.status,
|
||||||
"headers": dict(res.headers),
|
"headers": dict(res.headers),
|
||||||
"body": await res.text()
|
"body": text
|
||||||
}
|
}
|
||||||
|
|
||||||
async def ping(self, **kwargs):
|
async def ping(self, **kwargs):
|
||||||
@@ -173,11 +174,11 @@ class Utilities:
|
|||||||
return self.context.settings.setSetting(key, value)
|
return self.context.settings.setSetting(key, value)
|
||||||
|
|
||||||
async def allow_remote_debugging(self):
|
async def allow_remote_debugging(self):
|
||||||
await helpers.start_systemd_unit(helpers.REMOTE_DEBUGGER_UNIT)
|
await service_start(helpers.REMOTE_DEBUGGER_UNIT)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def disallow_remote_debugging(self):
|
async def disallow_remote_debugging(self):
|
||||||
await helpers.stop_systemd_unit(helpers.REMOTE_DEBUGGER_UNIT)
|
await service_stop(helpers.REMOTE_DEBUGGER_UNIT)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def filepicker_ls(self, path, include_files=True):
|
async def filepicker_ls(self, path, include_files=True):
|
||||||
@@ -232,7 +233,7 @@ class Utilities:
|
|||||||
self.rdt_proxy_server.close()
|
self.rdt_proxy_server.close()
|
||||||
self.rdt_proxy_task.cancel()
|
self.rdt_proxy_task.cancel()
|
||||||
|
|
||||||
async def enable_rdt(self):
|
async def _enable_rdt(self):
|
||||||
# TODO un-hardcode port
|
# TODO un-hardcode port
|
||||||
try:
|
try:
|
||||||
self.stop_rdt_proxy()
|
self.stop_rdt_proxy()
|
||||||
@@ -242,14 +243,26 @@ class Utilities:
|
|||||||
self.logger.info("Connecting to React DevTools at " + ip)
|
self.logger.info("Connecting to React DevTools at " + ip)
|
||||||
async with ClientSession() as web:
|
async with ClientSession() as web:
|
||||||
res = await web.request("GET", "http://" + ip + ":8097", ssl=helpers.get_ssl_context())
|
res = await web.request("GET", "http://" + ip + ":8097", ssl=helpers.get_ssl_context())
|
||||||
|
script = """
|
||||||
|
if (!window.deckyHasConnectedRDT) {
|
||||||
|
window.deckyHasConnectedRDT = true;
|
||||||
|
// This fixes the overlay when hovering over an element in RDT
|
||||||
|
Object.defineProperty(window, '__REACT_DEVTOOLS_TARGET_WINDOW__', {
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true,
|
||||||
|
get: function() {
|
||||||
|
return (GamepadNavTree?.m_context?.m_controller || FocusNavController)?.m_ActiveContext?.ActiveWindow || window;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
""" + await res.text() + "\n}"
|
||||||
if res.status != 200:
|
if res.status != 200:
|
||||||
self.logger.error("Failed to connect to React DevTools at " + ip)
|
self.logger.error("Failed to connect to React DevTools at " + ip)
|
||||||
return False
|
return False
|
||||||
self.start_rdt_proxy(ip, 8097)
|
self.start_rdt_proxy(ip, 8097)
|
||||||
script = "if(!window.deckyHasConnectedRDT){window.deckyHasConnectedRDT=true;\n" + await res.text() + "\n}"
|
|
||||||
self.logger.info("Connected to React DevTools, loading script")
|
self.logger.info("Connected to React DevTools, loading script")
|
||||||
tab = await get_gamepadui_tab()
|
tab = await get_gamepadui_tab()
|
||||||
# RDT needs to load before React itself to work.
|
# RDT needs to load before React itself to work.
|
||||||
|
await close_old_tabs()
|
||||||
result = await tab.reload_and_evaluate(script)
|
result = await tab.reload_and_evaluate(script)
|
||||||
self.logger.info(result)
|
self.logger.info(result)
|
||||||
|
|
||||||
@@ -257,9 +270,13 @@ class Utilities:
|
|||||||
self.logger.error("Failed to connect to React DevTools")
|
self.logger.error("Failed to connect to React DevTools")
|
||||||
self.logger.error(format_exc())
|
self.logger.error(format_exc())
|
||||||
|
|
||||||
|
async def enable_rdt(self):
|
||||||
|
self.context.loop.create_task(self._enable_rdt())
|
||||||
|
|
||||||
async def disable_rdt(self):
|
async def disable_rdt(self):
|
||||||
self.logger.info("Disabling React DevTools")
|
self.logger.info("Disabling React DevTools")
|
||||||
tab = await get_gamepadui_tab()
|
tab = await get_gamepadui_tab()
|
||||||
self.rdt_script_id = None
|
self.rdt_script_id = None
|
||||||
await tab.evaluate_js("SteamClient.User.StartRestart();", False, True, False)
|
await close_old_tabs()
|
||||||
|
await tab.evaluate_js("location.reload();", False, True, False)
|
||||||
self.logger.info("React DevTools disabled")
|
self.logger.info("React DevTools disabled")
|
||||||
|
|||||||
Vendored
+4
-1
@@ -11,10 +11,12 @@ HOMEBREW_FOLDER="${USER_DIR}/homebrew"
|
|||||||
rm -rf "${HOMEBREW_FOLDER}/services"
|
rm -rf "${HOMEBREW_FOLDER}/services"
|
||||||
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/services"
|
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/services"
|
||||||
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/plugins"
|
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/plugins"
|
||||||
|
touch "${USER_DIR}/.steam/steam/.cef-enable-remote-debugging"
|
||||||
|
|
||||||
# Download latest release and install it
|
# Download latest release and install it
|
||||||
RELEASE=$(curl -s 'https://api.github.com/repos/SteamDeckHomebrew/decky-loader/releases' | jq -r "first(.[] | select(.prerelease == "true"))")
|
RELEASE=$(curl -s 'https://api.github.com/repos/SteamDeckHomebrew/decky-loader/releases' | jq -r "first(.[] | select(.prerelease == "true"))")
|
||||||
read VERSION DOWNLOADURL < <(echo $(jq -r '.tag_name, .assets[].browser_download_url' <<< ${RELEASE}))
|
VERSION=$(jq -r '.tag_name' <<< ${RELEASE} )
|
||||||
|
DOWNLOADURL=$(jq -r '.assets[].browser_download_url | select(endswith("PluginLoader"))' <<< ${RELEASE})
|
||||||
|
|
||||||
printf "Installing version %s...\n" "${VERSION}"
|
printf "Installing version %s...\n" "${VERSION}"
|
||||||
curl -L $DOWNLOADURL --output ${HOMEBREW_FOLDER}/services/PluginLoader
|
curl -L $DOWNLOADURL --output ${HOMEBREW_FOLDER}/services/PluginLoader
|
||||||
@@ -40,6 +42,7 @@ User=root
|
|||||||
Restart=always
|
Restart=always
|
||||||
ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
|
ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
|
||||||
WorkingDirectory=${HOMEBREW_FOLDER}/services
|
WorkingDirectory=${HOMEBREW_FOLDER}/services
|
||||||
|
KillSignal=SIGKILL
|
||||||
Environment=PLUGIN_PATH=${HOMEBREW_FOLDER}/plugins
|
Environment=PLUGIN_PATH=${HOMEBREW_FOLDER}/plugins
|
||||||
Environment=LOG_LEVEL=DEBUG
|
Environment=LOG_LEVEL=DEBUG
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
Vendored
+4
-1
@@ -11,10 +11,12 @@ HOMEBREW_FOLDER="${USER_DIR}/homebrew"
|
|||||||
rm -rf "${HOMEBREW_FOLDER}/services"
|
rm -rf "${HOMEBREW_FOLDER}/services"
|
||||||
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/services"
|
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/services"
|
||||||
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/plugins"
|
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/plugins"
|
||||||
|
touch "${USER_DIR}/.steam/steam/.cef-enable-remote-debugging"
|
||||||
|
|
||||||
# Download latest release and install it
|
# Download latest release and install it
|
||||||
RELEASE=$(curl -s 'https://api.github.com/repos/SteamDeckHomebrew/decky-loader/releases' | jq -r "first(.[] | select(.prerelease == "false"))")
|
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}))
|
VERSION=$(jq -r '.tag_name' <<< ${RELEASE} )
|
||||||
|
DOWNLOADURL=$(jq -r '.assets[].browser_download_url | select(endswith("PluginLoader"))' <<< ${RELEASE})
|
||||||
|
|
||||||
printf "Installing version %s...\n" "${VERSION}"
|
printf "Installing version %s...\n" "${VERSION}"
|
||||||
curl -L $DOWNLOADURL --output ${HOMEBREW_FOLDER}/services/PluginLoader
|
curl -L $DOWNLOADURL --output ${HOMEBREW_FOLDER}/services/PluginLoader
|
||||||
@@ -40,6 +42,7 @@ User=root
|
|||||||
Restart=always
|
Restart=always
|
||||||
ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
|
ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
|
||||||
WorkingDirectory=${HOMEBREW_FOLDER}/services
|
WorkingDirectory=${HOMEBREW_FOLDER}/services
|
||||||
|
KillSignal=SIGKILL
|
||||||
Environment=PLUGIN_PATH=${HOMEBREW_FOLDER}/plugins
|
Environment=PLUGIN_PATH=${HOMEBREW_FOLDER}/plugins
|
||||||
Environment=LOG_LEVEL=INFO
|
Environment=LOG_LEVEL=INFO
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
+4
-2
@@ -8,7 +8,9 @@ User=root
|
|||||||
Restart=always
|
Restart=always
|
||||||
ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
|
ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
|
||||||
WorkingDirectory=${HOMEBREW_FOLDER}/services
|
WorkingDirectory=${HOMEBREW_FOLDER}/services
|
||||||
Environment=PLUGIN_PATH=${HOMEBREW_FOLDER}/plugins
|
KillSignal=SIGKILL
|
||||||
|
Environment=UNPRIVILEGED_PATH=${HOMEBREW_FOLDER}
|
||||||
|
Environment=PRIVILEGED_PATH=${HOMEBREW_FOLDER}
|
||||||
Environment=LOG_LEVEL=DEBUG
|
Environment=LOG_LEVEL=DEBUG
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
Vendored
+4
-2
@@ -8,7 +8,9 @@ User=root
|
|||||||
Restart=always
|
Restart=always
|
||||||
ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
|
ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
|
||||||
WorkingDirectory=${HOMEBREW_FOLDER}/services
|
WorkingDirectory=${HOMEBREW_FOLDER}/services
|
||||||
Environment=PLUGIN_PATH=${HOMEBREW_FOLDER}/plugins
|
KillSignal=SIGKILL
|
||||||
|
Environment=UNPRIVILEGED_PATH=${HOMEBREW_FOLDER}
|
||||||
|
Environment=PRIVILEGED_PATH=${HOMEBREW_FOLDER}
|
||||||
Environment=LOG_LEVEL=INFO
|
Environment=LOG_LEVEL=INFO
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="81.700577mm"
|
||||||
|
height="24.334814mm"
|
||||||
|
viewBox="0 0 81.700577 24.334814"
|
||||||
|
version="1.1"
|
||||||
|
id="svg5"
|
||||||
|
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
|
||||||
|
sodipodi:docname="download.svg"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview7"
|
||||||
|
pagecolor="#505050"
|
||||||
|
bordercolor="#ffffff"
|
||||||
|
borderopacity="1"
|
||||||
|
inkscape:showpageshadow="0"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pagecheckerboard="1"
|
||||||
|
inkscape:deskcolor="#505050"
|
||||||
|
inkscape:document-units="mm"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="3.659624"
|
||||||
|
inkscape:cx="115.44902"
|
||||||
|
inkscape:cy="59.295709"
|
||||||
|
inkscape:window-width="1827"
|
||||||
|
inkscape:window-height="1233"
|
||||||
|
inkscape:window-x="69"
|
||||||
|
inkscape:window-y="38"
|
||||||
|
inkscape:window-maximized="0"
|
||||||
|
inkscape:current-layer="layer1" />
|
||||||
|
<defs
|
||||||
|
id="defs2">
|
||||||
|
<linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
id="linearGradient4494">
|
||||||
|
<stop
|
||||||
|
style="stop-color:#009fff;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop4490" />
|
||||||
|
<stop
|
||||||
|
style="stop-color:#ff1965;stop-opacity:1;"
|
||||||
|
offset="0.79417855"
|
||||||
|
id="stop4498" />
|
||||||
|
<stop
|
||||||
|
style="stop-color:#b9b500;stop-opacity:1;"
|
||||||
|
offset="1"
|
||||||
|
id="stop4492" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient4494"
|
||||||
|
id="linearGradient4496"
|
||||||
|
x1="49.131042"
|
||||||
|
y1="118.6573"
|
||||||
|
x2="150.29259"
|
||||||
|
y2="138.74957"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
spreadMethod="pad"
|
||||||
|
gradientTransform="matrix(1.0500324,0,0,1,-1.6155884,24.621921)" />
|
||||||
|
<linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient4494"
|
||||||
|
id="linearGradient13802"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(1.0500324,0,0,1,-1.6155884,24.621921)"
|
||||||
|
x1="49.131042"
|
||||||
|
y1="118.6573"
|
||||||
|
x2="150.29259"
|
||||||
|
y2="138.74957"
|
||||||
|
spreadMethod="pad" />
|
||||||
|
</defs>
|
||||||
|
<g
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-64.149712,-136.3326)">
|
||||||
|
<rect
|
||||||
|
style="mix-blend-mode:normal;fill:url(#linearGradient13802);fill-opacity:1;stroke:none;stroke-width:0.271121"
|
||||||
|
id="rect111"
|
||||||
|
width="81.700577"
|
||||||
|
height="24.334814"
|
||||||
|
x="64.149712"
|
||||||
|
y="136.3326"
|
||||||
|
ry="8.1781616" />
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:3.175px;fill:#000000;stroke:none;stroke-width:0.264583"
|
||||||
|
x="66.364288"
|
||||||
|
y="124.84658"
|
||||||
|
id="text10382"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan10380"
|
||||||
|
style="stroke-width:0.264583"
|
||||||
|
x="66.364288"
|
||||||
|
y="124.84658" /></text>
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:15.1694px;font-family:sans-serif;-inkscape-font-specification:sans-serif;white-space:pre;inline-size:82.6483;display:inline;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.264583"
|
||||||
|
x="67.732498"
|
||||||
|
y="126.05277"
|
||||||
|
id="text10440"
|
||||||
|
transform="translate(1.088576,28.135753)"><tspan
|
||||||
|
x="67.732498"
|
||||||
|
y="126.05277"
|
||||||
|
id="tspan13872">Download</tspan></text>
|
||||||
|
<rect
|
||||||
|
style="mix-blend-mode:normal;fill:url(#linearGradient4496);fill-opacity:1;stroke:none;stroke-width:0.271121"
|
||||||
|
id="rect13792"
|
||||||
|
width="81.700577"
|
||||||
|
height="24.334814"
|
||||||
|
x="64.149712"
|
||||||
|
y="136.3326"
|
||||||
|
ry="8.1781616" />
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:3.175px;fill:#000000;stroke:none;stroke-width:0.264583"
|
||||||
|
x="66.364288"
|
||||||
|
y="124.84658"
|
||||||
|
id="text13796"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan13794"
|
||||||
|
style="stroke-width:0.264583"
|
||||||
|
x="66.364288"
|
||||||
|
y="124.84658" /></text>
|
||||||
|
<g
|
||||||
|
aria-label="Download"
|
||||||
|
transform="translate(1.088576,28.135753)"
|
||||||
|
id="text13800"
|
||||||
|
style="font-size:15.1694px;-inkscape-font-specification:sans-serif;white-space:pre;inline-size:82.6483;display:inline;fill:#ffffff;stroke-width:0.264583">
|
||||||
|
<path
|
||||||
|
d="m 77.880751,120.53111 q 0,2.74566 -1.501771,4.14125 -1.486601,1.38041 -4.156416,1.38041 h -3.01871 v -10.83095 h 3.337268 q 1.638295,0 2.836678,0.60678 1.198382,0.60677 1.850666,1.78999 0.652285,1.16804 0.652285,2.91252 z m -1.441093,0.0455 q 0,-2.16923 -1.077028,-3.17041 -1.061858,-1.01635 -3.01871,-1.01635 H 70.5691 v 8.49487 h 1.471432 q 4.399126,0 4.399126,-4.30811 z"
|
||||||
|
id="path13828" />
|
||||||
|
<path
|
||||||
|
d="m 87.164417,121.9722 q 0,2.01753 -1.03152,3.1249 -1.01635,1.10737 -2.760831,1.10737 -1.077027,0 -1.926513,-0.48542 -0.834317,-0.50059 -1.319738,-1.4411 -0.485421,-0.95567 -0.485421,-2.30575 0,-2.01753 1.01635,-3.10972 1.01635,-1.0922 2.760831,-1.0922 1.107366,0 1.941683,0.50059 0.849486,0.48542 1.319738,1.42592 0.485421,0.92534 0.485421,2.27541 z m -6.143608,0 q 0,1.4411 0.561268,2.29058 0.576437,0.83432 1.820328,0.83432 1.228722,0 1.805159,-0.83432 0.576437,-0.84948 0.576437,-2.29058 0,-1.44109 -0.576437,-2.26024 -0.576437,-0.81914 -1.820328,-0.81914 -1.243891,0 -1.805159,0.81914 -0.561268,0.81915 -0.561268,2.26024 z"
|
||||||
|
id="path13830" />
|
||||||
|
<path
|
||||||
|
d="m 94.218174,121.45644 q -0.197202,-0.62194 -0.348896,-1.21355 -0.136525,-0.60677 -0.212372,-0.9405 h -0.06068 q -0.06068,0.33373 -0.197203,0.9405 -0.136524,0.59161 -0.348896,1.22872 l -1.456262,4.56599 h -1.51694 l -2.229902,-8.1308 h 1.380415 l 1.122536,4.33845 q 0.166863,0.65229 0.318557,1.31974 0.151694,0.66745 0.212372,1.10737 h 0.06068 q 0.06068,-0.25788 0.136525,-0.63712 0.09102,-0.37923 0.197202,-0.78881 0.106186,-0.42474 0.212372,-0.75847 l 1.441093,-4.58116 h 1.456262 l 1.395585,4.58116 q 0.166864,0.51576 0.318558,1.12254 0.166863,0.60678 0.227541,1.04669 h 0.06068 q 0.04551,-0.37924 0.197202,-1.04669 0.166864,-0.66745 0.348897,-1.36525 l 1.137705,-4.33845 h 1.365246 l -2.260241,8.1308 h -1.562448 z"
|
||||||
|
id="path13832" />
|
||||||
|
<path
|
||||||
|
d="m 104.8064,117.77028 q 1.45627,0 2.19957,0.71296 0.7433,0.69779 0.7433,2.27541 v 5.29412 h -1.31974 v -5.2031 q 0,-1.95685 -1.82033,-1.95685 -1.35007,0 -1.86583,0.75847 -0.51576,0.75847 -0.51576,2.18439 v 4.21709 h -1.33491 v -8.1308 h 1.07703 l 0.1972,1.10737 h 0.0759 q 0.3944,-0.63711 1.09219,-0.9405 0.69779,-0.31856 1.47143,-0.31856 z"
|
||||||
|
id="path13834" />
|
||||||
|
<path
|
||||||
|
d="m 111.6023,126.05277 h -1.33491 v -11.52874 h 1.33491 z"
|
||||||
|
id="path13836" />
|
||||||
|
<path
|
||||||
|
d="m 121.25003,121.9722 q 0,2.01753 -1.03152,3.1249 -1.01635,1.10737 -2.76084,1.10737 -1.07702,0 -1.92651,-0.48542 -0.83432,-0.50059 -1.31974,-1.4411 -0.48542,-0.95567 -0.48542,-2.30575 0,-2.01753 1.01635,-3.10972 1.01635,-1.0922 2.76083,-1.0922 1.10737,0 1.94169,0.50059 0.84948,0.48542 1.31973,1.42592 0.48543,0.92534 0.48543,2.27541 z m -6.14361,0 q 0,1.4411 0.56127,2.29058 0.57643,0.83432 1.82032,0.83432 1.22873,0 1.80516,-0.83432 0.57644,-0.84948 0.57644,-2.29058 0,-1.44109 -0.57644,-2.26024 -0.57643,-0.81914 -1.82033,-0.81914 -1.24389,0 -1.80515,0.81914 -0.56127,0.81915 -0.56127,2.26024 z"
|
||||||
|
id="path13838" />
|
||||||
|
<path
|
||||||
|
d="m 126.43796,117.78545 q 1.4866,0 2.19956,0.65228 0.71296,0.65229 0.71296,2.07821 v 5.53683 h -0.97084 l -0.25788,-1.15287 h -0.0607 q -0.53093,0.66745 -1.12253,0.98601 -0.57644,0.31856 -1.60796,0.31856 -1.10737,0 -1.8355,-0.57644 -0.72813,-0.59161 -0.72813,-1.8355 0,-1.21355 0.95567,-1.86583 0.95567,-0.66746 2.94287,-0.72814 l 1.38041,-0.0455 v -0.48542 q 0,-1.01635 -0.43991,-1.41076 -0.43991,-0.3944 -1.24389,-0.3944 -0.63712,0 -1.21355,0.1972 -0.57644,0.18203 -1.07703,0.42474 l -0.40957,-1.00118 q 0.53092,-0.28822 1.25906,-0.48542 0.72813,-0.21237 1.51694,-0.21237 z m 0.3944,4.33845 q -1.51694,0.0607 -2.10855,0.48542 -0.57643,0.42474 -0.57643,1.19838 0,0.68262 0.40957,1.00118 0.42474,0.31856 1.07703,0.31856 1.03152,0 1.71414,-0.56127 0.68262,-0.57644 0.68262,-1.75965 v -0.72813 z"
|
||||||
|
id="path13840" />
|
||||||
|
<path
|
||||||
|
d="m 134.7508,126.20447 q -1.51694,0 -2.42711,-1.04669 -0.91016,-1.06186 -0.91016,-3.15524 0,-2.09337 0.91016,-3.15523 0.92534,-1.07703 2.44228,-1.07703 0.9405,0 1.53211,0.3489 0.60677,0.34889 0.98601,0.84948 h 0.091 q -0.0152,-0.1972 -0.0607,-0.57643 -0.0303,-0.39441 -0.0303,-0.62195 v -3.24625 h 1.3349 v 11.52874 h -1.07702 l -0.19721,-1.09219 h -0.0607 q -0.36407,0.51576 -0.97084,0.87982 -0.60678,0.36407 -1.56245,0.36407 z m 0.21237,-1.10737 q 1.2894,0 1.80516,-0.69779 0.53093,-0.71296 0.53093,-2.13889 v -0.24271 q 0,-1.51694 -0.50059,-2.32092 -0.50059,-0.81914 -1.85067,-0.81914 -1.07703,0 -1.62313,0.86465 -0.53093,0.84949 -0.53093,2.29058 0,1.45626 0.53093,2.26024 0.5461,0.80398 1.6383,0.80398 z"
|
||||||
|
id="path13842" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 8.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
declare module '*.png';
|
||||||
|
declare module '*.jpg';
|
||||||
+13
-12
@@ -12,27 +12,28 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rollup/plugin-commonjs": "^21.1.0",
|
"@rollup/plugin-commonjs": "^21.1.0",
|
||||||
|
"@rollup/plugin-image": "^3.0.2",
|
||||||
"@rollup/plugin-json": "^4.1.0",
|
"@rollup/plugin-json": "^4.1.0",
|
||||||
"@rollup/plugin-node-resolve": "^13.3.0",
|
"@rollup/plugin-node-resolve": "^13.3.0",
|
||||||
"@rollup/plugin-replace": "^4.0.0",
|
"@rollup/plugin-replace": "^4.0.0",
|
||||||
"@rollup/plugin-typescript": "^8.3.3",
|
"@rollup/plugin-typescript": "^8.5.0",
|
||||||
"@types/react": "16.14.0",
|
"@types/react": "16.14.0",
|
||||||
"@types/react-file-icon": "^1.0.1",
|
"@types/react-file-icon": "^1.0.1",
|
||||||
"@types/react-router": "5.1.18",
|
"@types/react-router": "5.1.18",
|
||||||
"@types/webpack": "^5.28.0",
|
"@types/webpack": "^5.28.1",
|
||||||
"husky": "^8.0.1",
|
"husky": "^8.0.3",
|
||||||
"import-sort-style-module": "^6.0.0",
|
"import-sort-style-module": "^6.0.0",
|
||||||
"inquirer": "^8.2.4",
|
"inquirer": "^8.2.5",
|
||||||
"prettier": "^2.7.1",
|
"prettier": "^2.8.7",
|
||||||
"prettier-plugin-import-sort": "^0.0.7",
|
"prettier-plugin-import-sort": "^0.0.7",
|
||||||
"react": "16.14.0",
|
"react": "16.14.0",
|
||||||
"react-dom": "16.14.0",
|
"react-dom": "16.14.0",
|
||||||
"rollup": "^2.76.0",
|
"rollup": "^2.79.1",
|
||||||
"rollup-plugin-delete": "^2.0.0",
|
"rollup-plugin-delete": "^2.0.0",
|
||||||
"rollup-plugin-external-globals": "^0.6.1",
|
"rollup-plugin-external-globals": "^0.6.1",
|
||||||
"rollup-plugin-polyfill-node": "^0.10.2",
|
"rollup-plugin-polyfill-node": "^0.10.2",
|
||||||
"tslib": "^2.4.0",
|
"tslib": "^2.5.0",
|
||||||
"typescript": "^4.7.4"
|
"typescript": "^4.9.5"
|
||||||
},
|
},
|
||||||
"importSort": {
|
"importSort": {
|
||||||
".js, .jsx, .ts, .tsx": {
|
".js, .jsx, .ts, .tsx": {
|
||||||
@@ -41,10 +42,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"decky-frontend-lib": "^3.7.14",
|
"decky-frontend-lib": "3.20.6",
|
||||||
"react-file-icon": "^1.2.0",
|
"react-file-icon": "^1.3.0",
|
||||||
"react-icons": "^4.4.0",
|
"react-icons": "^4.8.0",
|
||||||
"react-markdown": "^8.0.3",
|
"react-markdown": "^8.0.6",
|
||||||
"remark-gfm": "^3.0.1"
|
"remark-gfm": "^3.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+799
-727
File diff suppressed because it is too large
Load Diff
+15
-16
@@ -1,30 +1,28 @@
|
|||||||
import commonjs from '@rollup/plugin-commonjs';
|
import commonjs from '@rollup/plugin-commonjs';
|
||||||
|
import image from '@rollup/plugin-image';
|
||||||
import json from '@rollup/plugin-json';
|
import json from '@rollup/plugin-json';
|
||||||
import { nodeResolve } from '@rollup/plugin-node-resolve';
|
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 replace from '@rollup/plugin-replace';
|
||||||
import typescript from '@rollup/plugin-typescript';
|
import typescript from '@rollup/plugin-typescript';
|
||||||
import { defineConfig, handleWarning } from 'rollup';
|
import { defineConfig } from 'rollup';
|
||||||
|
import del from 'rollup-plugin-delete';
|
||||||
|
import externalGlobals from 'rollup-plugin-external-globals';
|
||||||
|
|
||||||
const hiddenWarnings = [
|
const hiddenWarnings = ['THIS_IS_UNDEFINED', 'EVAL'];
|
||||||
"THIS_IS_UNDEFINED",
|
|
||||||
"EVAL"
|
|
||||||
];
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
input: 'src/index.tsx',
|
input: 'src/index.tsx',
|
||||||
plugins: [
|
plugins: [
|
||||||
del({ targets: "../backend/static/*", force: true }),
|
del({ targets: '../backend/static/*', force: true }),
|
||||||
commonjs(),
|
commonjs(),
|
||||||
nodeResolve(),
|
nodeResolve(),
|
||||||
externalGlobals({
|
externalGlobals({
|
||||||
react: 'SP_REACT',
|
react: 'SP_REACT',
|
||||||
'react-dom': 'SP_REACTDOM',
|
'react-dom': 'SP_REACTDOM',
|
||||||
// hack to shut up react-markdown
|
// hack to shut up react-markdown
|
||||||
'process': '{cwd: () => {}}',
|
process: '{cwd: () => {}}',
|
||||||
'path': '{dirname: () => {}, join: () => {}, basename: () => {}, extname: () => {}}',
|
path: '{dirname: () => {}, join: () => {}, basename: () => {}, extname: () => {}}',
|
||||||
'url': '{fileURLToPath: (f) => f}'
|
url: '{fileURLToPath: (f) => f}',
|
||||||
}),
|
}),
|
||||||
typescript(),
|
typescript(),
|
||||||
json(),
|
json(),
|
||||||
@@ -32,17 +30,18 @@ export default defineConfig({
|
|||||||
preventAssignment: false,
|
preventAssignment: false,
|
||||||
'process.env.NODE_ENV': JSON.stringify('production'),
|
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||||
}),
|
}),
|
||||||
|
image(),
|
||||||
],
|
],
|
||||||
preserveEntrySignatures: false,
|
preserveEntrySignatures: false,
|
||||||
output: {
|
output: {
|
||||||
dir: '../backend/static',
|
dir: '../backend/static',
|
||||||
format: 'esm',
|
format: 'esm',
|
||||||
chunkFileNames: (chunkInfo) => {
|
chunkFileNames: (chunkInfo) => {
|
||||||
return 'chunk-[hash].js'
|
return 'chunk-[hash].js';
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
onwarn: function ( message ) {
|
onwarn: function (message, handleWarning) {
|
||||||
if (hiddenWarnings.some(warning => message.code === warning)) return;
|
if (hiddenWarnings.some((warning) => message.code === warning)) return;
|
||||||
handleWarning(message);
|
handleWarning(message);
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
export default function DeckyIcon() {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 456" width="512" height="456">
|
||||||
|
<g>
|
||||||
|
<path
|
||||||
|
style={{ fill: 'none' }}
|
||||||
|
d="M154.33,72.51v49.79c11.78-0.17,23.48,2,34.42,6.39c10.93,4.39,20.89,10.91,29.28,19.18
|
||||||
|
c8.39,8.27,15.06,18.13,19.61,29c4.55,10.87,6.89,22.54,6.89,34.32c0,11.78-2.34,23.45-6.89,34.32
|
||||||
|
c-4.55,10.87-11.21,20.73-19.61,29c-8.39,8.27-18.35,14.79-29.28,19.18c-10.94,4.39-22.63,6.56-34.42,6.39v49.77
|
||||||
|
c36.78,0,72.05-14.61,98.05-40.62c26-26.01,40.61-61.28,40.61-98.05c0-36.78-14.61-72.05-40.61-98.05
|
||||||
|
C226.38,87.12,191.11,72.51,154.33,72.51z"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ellipse
|
||||||
|
transform="matrix(0.982 -0.1891 0.1891 0.982 -37.1795 32.9988)"
|
||||||
|
style={{ fill: 'none' }}
|
||||||
|
cx="154.33"
|
||||||
|
cy="211.33"
|
||||||
|
rx="69.33"
|
||||||
|
ry="69.33"
|
||||||
|
/>
|
||||||
|
<path style={{ fill: 'none' }} d="M430,97h-52v187h52c7.18,0,13-5.82,13-13V110C443,102.82,437.18,97,430,97z" />
|
||||||
|
<path
|
||||||
|
style={{ fill: 'currentColor' }}
|
||||||
|
d="M432,27h-54V0H0v361c0,52.47,42.53,95,95,95h188c52.47,0,95-42.53,95-95v-7h54c44.18,0,80-35.82,80-80V107
|
||||||
|
C512,62.82,476.18,27,432,27z M85,211.33c0-38.29,31.04-69.33,69.33-69.33c38.29,0,69.33,31.04,69.33,69.33
|
||||||
|
c0,38.29-31.04,69.33-69.33,69.33C116.04,280.67,85,249.62,85,211.33z M252.39,309.23c-26.01,26-61.28,40.62-98.05,40.62v-49.77
|
||||||
|
c11.78,0.17,23.48-2,34.42-6.39c10.93-4.39,20.89-10.91,29.28-19.18c8.39-8.27,15.06-18.13,19.61-29
|
||||||
|
c4.55-10.87,6.89-22.53,6.89-34.32c0-11.78-2.34-23.45-6.89-34.32c-4.55-10.87-11.21-20.73-19.61-29
|
||||||
|
c-8.39-8.27-18.35-14.79-29.28-19.18c-10.94-4.39-22.63-6.56-34.42-6.39V72.51c36.78,0,72.05,14.61,98.05,40.61
|
||||||
|
c26,26.01,40.61,61.28,40.61,98.05C293,247.96,278.39,283.23,252.39,309.23z M443,271c0,7.18-5.82,13-13,13h-52V97h52
|
||||||
|
c7.18,0,13,5.82,13,13V271z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { VerInfo } from '../updater';
|
|||||||
|
|
||||||
interface PublicDeckyState {
|
interface PublicDeckyState {
|
||||||
plugins: Plugin[];
|
plugins: Plugin[];
|
||||||
|
pluginOrder: string[];
|
||||||
activePlugin: Plugin | null;
|
activePlugin: Plugin | null;
|
||||||
updates: PluginUpdateMapping | null;
|
updates: PluginUpdateMapping | null;
|
||||||
hasLoaderUpdate?: boolean;
|
hasLoaderUpdate?: boolean;
|
||||||
@@ -15,6 +16,7 @@ interface PublicDeckyState {
|
|||||||
|
|
||||||
export class DeckyState {
|
export class DeckyState {
|
||||||
private _plugins: Plugin[] = [];
|
private _plugins: Plugin[] = [];
|
||||||
|
private _pluginOrder: string[] = [];
|
||||||
private _activePlugin: Plugin | null = null;
|
private _activePlugin: Plugin | null = null;
|
||||||
private _updates: PluginUpdateMapping | null = null;
|
private _updates: PluginUpdateMapping | null = null;
|
||||||
private _hasLoaderUpdate: boolean = false;
|
private _hasLoaderUpdate: boolean = false;
|
||||||
@@ -26,6 +28,7 @@ export class DeckyState {
|
|||||||
publicState(): PublicDeckyState {
|
publicState(): PublicDeckyState {
|
||||||
return {
|
return {
|
||||||
plugins: this._plugins,
|
plugins: this._plugins,
|
||||||
|
pluginOrder: this._pluginOrder,
|
||||||
activePlugin: this._activePlugin,
|
activePlugin: this._activePlugin,
|
||||||
updates: this._updates,
|
updates: this._updates,
|
||||||
hasLoaderUpdate: this._hasLoaderUpdate,
|
hasLoaderUpdate: this._hasLoaderUpdate,
|
||||||
@@ -44,6 +47,11 @@ export class DeckyState {
|
|||||||
this.notifyUpdate();
|
this.notifyUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setPluginOrder(pluginOrder: string[]) {
|
||||||
|
this._pluginOrder = pluginOrder;
|
||||||
|
this.notifyUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
setActivePlugin(name: string) {
|
setActivePlugin(name: string) {
|
||||||
this._activePlugin = this._plugins.find((plugin) => plugin.name === name) ?? null;
|
this._activePlugin = this._plugins.find((plugin) => plugin.name === name) ?? null;
|
||||||
this.notifyUpdate();
|
this.notifyUpdate();
|
||||||
@@ -78,6 +86,7 @@ interface DeckyStateContext extends PublicDeckyState {
|
|||||||
setVersionInfo(versionInfo: VerInfo): void;
|
setVersionInfo(versionInfo: VerInfo): void;
|
||||||
setIsLoaderUpdating(hasUpdate: boolean): void;
|
setIsLoaderUpdating(hasUpdate: boolean): void;
|
||||||
setActivePlugin(name: string): void;
|
setActivePlugin(name: string): void;
|
||||||
|
setPluginOrder(pluginOrder: string[]): void;
|
||||||
closeActivePlugin(): void;
|
closeActivePlugin(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,10 +115,18 @@ export const DeckyStateContextProvider: FC<Props> = ({ children, deckyState }) =
|
|||||||
const setVersionInfo = (versionInfo: VerInfo) => deckyState.setVersionInfo(versionInfo);
|
const setVersionInfo = (versionInfo: VerInfo) => deckyState.setVersionInfo(versionInfo);
|
||||||
const setActivePlugin = (name: string) => deckyState.setActivePlugin(name);
|
const setActivePlugin = (name: string) => deckyState.setActivePlugin(name);
|
||||||
const closeActivePlugin = () => deckyState.closeActivePlugin();
|
const closeActivePlugin = () => deckyState.closeActivePlugin();
|
||||||
|
const setPluginOrder = (pluginOrder: string[]) => deckyState.setPluginOrder(pluginOrder);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DeckyStateContext.Provider
|
<DeckyStateContext.Provider
|
||||||
value={{ ...publicDeckyState, setIsLoaderUpdating, setVersionInfo, setActivePlugin, closeActivePlugin }}
|
value={{
|
||||||
|
...publicDeckyState,
|
||||||
|
setIsLoaderUpdating,
|
||||||
|
setVersionInfo,
|
||||||
|
setActivePlugin,
|
||||||
|
closeActivePlugin,
|
||||||
|
setPluginOrder,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</DeckyStateContext.Provider>
|
</DeckyStateContext.Provider>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Focusable, Router } from 'decky-frontend-lib';
|
import { Focusable, Navigation } from 'decky-frontend-lib';
|
||||||
import { FunctionComponent, useRef } from 'react';
|
import { FunctionComponent, useRef } from 'react';
|
||||||
import ReactMarkdown, { Options as ReactMarkdownOptions } from 'react-markdown';
|
import ReactMarkdown, { Options as ReactMarkdownOptions } from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
@@ -22,7 +22,7 @@ const Markdown: FunctionComponent<MarkdownProps> = (props) => {
|
|||||||
onActivate={() => {}}
|
onActivate={() => {}}
|
||||||
onOKButton={() => {
|
onOKButton={() => {
|
||||||
props.onDismiss?.();
|
props.onDismiss?.();
|
||||||
Router.NavigateToExternalWeb(aRef.current!.href);
|
Navigation.NavigateToExternalWeb(aRef.current!.href);
|
||||||
}}
|
}}
|
||||||
style={{ display: 'inline' }}
|
style={{ display: 'inline' }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,17 +7,27 @@ import {
|
|||||||
scrollClasses,
|
scrollClasses,
|
||||||
staticClasses,
|
staticClasses,
|
||||||
} from 'decky-frontend-lib';
|
} from 'decky-frontend-lib';
|
||||||
import { VFC } from 'react';
|
import { VFC, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { Plugin } from '../plugin';
|
||||||
import { useDeckyState } from './DeckyState';
|
import { useDeckyState } from './DeckyState';
|
||||||
import NotificationBadge from './NotificationBadge';
|
import NotificationBadge from './NotificationBadge';
|
||||||
import { useQuickAccessVisible } from './QuickAccessVisibleState';
|
import { useQuickAccessVisible } from './QuickAccessVisibleState';
|
||||||
import TitleView from './TitleView';
|
import TitleView from './TitleView';
|
||||||
|
|
||||||
const PluginView: VFC = () => {
|
const PluginView: VFC = () => {
|
||||||
const { plugins, updates, activePlugin, setActivePlugin, closeActivePlugin } = useDeckyState();
|
const { plugins, updates, activePlugin, pluginOrder, setActivePlugin, closeActivePlugin } = useDeckyState();
|
||||||
const visible = useQuickAccessVisible();
|
const visible = useQuickAccessVisible();
|
||||||
|
|
||||||
|
const [pluginList, setPluginList] = useState<Plugin[]>(
|
||||||
|
plugins.sort((a, b) => pluginOrder.indexOf(a.name) - pluginOrder.indexOf(b.name)),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPluginList(plugins.sort((a, b) => pluginOrder.indexOf(a.name) - pluginOrder.indexOf(b.name)));
|
||||||
|
console.log('updating PluginView after changes');
|
||||||
|
}, [plugins, pluginOrder]);
|
||||||
|
|
||||||
if (activePlugin) {
|
if (activePlugin) {
|
||||||
return (
|
return (
|
||||||
<Focusable onCancelButton={closeActivePlugin}>
|
<Focusable onCancelButton={closeActivePlugin}>
|
||||||
@@ -36,7 +46,7 @@ const PluginView: VFC = () => {
|
|||||||
<TitleView />
|
<TitleView />
|
||||||
<div className={joinClassNames(staticClasses.TabGroupPanel, scrollClasses.ScrollPanel, scrollClasses.ScrollY)}>
|
<div className={joinClassNames(staticClasses.TabGroupPanel, scrollClasses.ScrollPanel, scrollClasses.ScrollY)}>
|
||||||
<PanelSection>
|
<PanelSection>
|
||||||
{plugins
|
{pluginList
|
||||||
.filter((p) => p.content)
|
.filter((p) => p.content)
|
||||||
.map(({ name, icon }) => (
|
.map(({ name, icon }) => (
|
||||||
<PanelSectionRow key={name}>
|
<PanelSectionRow key={name}>
|
||||||
|
|||||||
@@ -4,15 +4,11 @@ const QuickAccessVisibleState = createContext<boolean>(true);
|
|||||||
|
|
||||||
export const useQuickAccessVisible = () => useContext(QuickAccessVisibleState);
|
export const useQuickAccessVisible = () => useContext(QuickAccessVisibleState);
|
||||||
|
|
||||||
export const QuickAccessVisibleStateProvider: FC<{ initial: boolean; setter: ((val: boolean) => {}[]) | never[] }> = ({
|
export const QuickAccessVisibleStateProvider: FC<{ initial: boolean; tab: any }> = ({ children, initial, tab }) => {
|
||||||
children,
|
|
||||||
initial,
|
|
||||||
setter,
|
|
||||||
}) => {
|
|
||||||
const [visible, setVisible] = useState<boolean>(initial);
|
const [visible, setVisible] = useState<boolean>(initial);
|
||||||
const [prev, setPrev] = useState<boolean>(initial);
|
const [prev, setPrev] = useState<boolean>(initial);
|
||||||
// hack to use an array as a "pointer" to pass the setter up the tree
|
// HACK but i can't think of a better way to do this
|
||||||
setter[0] = setVisible;
|
tab.qAMVisibilitySetter = setVisible;
|
||||||
if (initial != prev) {
|
if (initial != prev) {
|
||||||
setPrev(initial);
|
setPrev(initial);
|
||||||
setVisible(initial);
|
setVisible(initial);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { DialogButton, Focusable, Router, staticClasses } from 'decky-frontend-lib';
|
import { DialogButton, Focusable, Router, staticClasses } from 'decky-frontend-lib';
|
||||||
import { CSSProperties, VFC } from 'react';
|
import { CSSProperties, VFC } from 'react';
|
||||||
import { FaArrowLeft, FaCog, FaStore } from 'react-icons/fa';
|
import { BsGearFill } from 'react-icons/bs';
|
||||||
|
import { FaArrowLeft, FaStore } from 'react-icons/fa';
|
||||||
|
|
||||||
import { useDeckyState } from './DeckyState';
|
import { useDeckyState } from './DeckyState';
|
||||||
|
|
||||||
@@ -26,12 +27,6 @@ const TitleView: VFC = () => {
|
|||||||
if (activePlugin === null) {
|
if (activePlugin === null) {
|
||||||
return (
|
return (
|
||||||
<Focusable style={titleStyles} className={staticClasses.Title}>
|
<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>
|
<div style={{ marginRight: 'auto', flex: 0.9 }}>Decky</div>
|
||||||
<DialogButton
|
<DialogButton
|
||||||
style={{ height: '28px', width: '40px', minWidth: 0, padding: '10px 12px' }}
|
style={{ height: '28px', width: '40px', minWidth: 0, padding: '10px 12px' }}
|
||||||
@@ -39,6 +34,12 @@ const TitleView: VFC = () => {
|
|||||||
>
|
>
|
||||||
<FaStore style={{ marginTop: '-4px', display: 'block' }} />
|
<FaStore style={{ marginTop: '-4px', display: 'block' }} />
|
||||||
</DialogButton>
|
</DialogButton>
|
||||||
|
<DialogButton
|
||||||
|
style={{ height: '28px', width: '40px', minWidth: 0, padding: '10px 12px' }}
|
||||||
|
onClick={onSettingsClick}
|
||||||
|
>
|
||||||
|
<BsGearFill style={{ marginTop: '-4px', display: 'block' }} />
|
||||||
|
</DialogButton>
|
||||||
</Focusable>
|
</Focusable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ConfirmModal, QuickAccessTab, Router, Spinner, staticClasses } from 'decky-frontend-lib';
|
import { ConfirmModal, Navigation, QuickAccessTab } from 'decky-frontend-lib';
|
||||||
import { FC, useState } from 'react';
|
import { FC, useState } from 'react';
|
||||||
|
|
||||||
interface PluginInstallModalProps {
|
interface PluginInstallModalProps {
|
||||||
@@ -20,21 +20,20 @@ const PluginInstallModal: FC<PluginInstallModalProps> = ({ artifact, version, ha
|
|||||||
onOK={async () => {
|
onOK={async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await onOK();
|
await onOK();
|
||||||
setTimeout(() => Router.OpenQuickAccessMenu(QuickAccessTab.Decky), 250);
|
setTimeout(() => Navigation.OpenQuickAccessMenu(QuickAccessTab.Decky), 250);
|
||||||
setTimeout(() => window.DeckyPluginLoader.checkPluginUpdates(), 1000);
|
setTimeout(() => window.DeckyPluginLoader.checkPluginUpdates(), 1000);
|
||||||
}}
|
}}
|
||||||
onCancel={async () => {
|
onCancel={async () => {
|
||||||
await onCancel();
|
await onCancel();
|
||||||
}}
|
}}
|
||||||
|
strTitle={`Install ${artifact}`}
|
||||||
|
strOKButtonText={loading ? 'Installing' : 'Install'}
|
||||||
>
|
>
|
||||||
<div className={staticClasses.Title} style={{ flexDirection: 'column' }}>
|
Are you sure you want to install {artifact}
|
||||||
{hash == 'False' ? <h3 style={{ color: 'red' }}>!!!!NO HASH PROVIDED!!!!</h3> : null}
|
{version ? ` ${version}` : ''}?
|
||||||
<div style={{ flexDirection: 'row' }}>
|
{hash == 'False' && (
|
||||||
{loading && <Spinner style={{ width: '20px' }} />} {loading ? 'Installing' : 'Install'} {artifact}
|
<span style={{ color: 'red' }}> This plugin does not have a hash, you are installing it at your own risk.</span>
|
||||||
{version ? ' version ' + version : null}
|
)}
|
||||||
{!loading && '?'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ConfirmModal>
|
</ConfirmModal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -134,7 +134,15 @@ const FilePicker: FunctionComponent<FilePickerProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{file.name}
|
<span
|
||||||
|
style={{
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textAlign: 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{file.name}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</DialogButton>
|
</DialogButton>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,45 +1,52 @@
|
|||||||
import { Patch, findModuleChild, replacePatch } from 'decky-frontend-lib';
|
import { Patch, findModuleChild, replacePatch, sleep } from 'decky-frontend-lib';
|
||||||
|
|
||||||
declare global {
|
import Logger from '../../../../logger';
|
||||||
interface Window {
|
|
||||||
SteamClient: any;
|
const logger = new Logger('LibraryPatch');
|
||||||
appDetailsStore: any;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let patch: Patch;
|
let patch: Patch;
|
||||||
|
|
||||||
function rePatch() {
|
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
|
// 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 with History.listen or an interval
|
||||||
patch = replacePatch(window.SteamClient.Apps, 'PromptToChangeShortcut', async ([appid]: number[]) => {
|
patch = replacePatch(window.SteamClient.Apps, 'PromptToChangeShortcut', async ([appid]: number[]) => {
|
||||||
try {
|
try {
|
||||||
const details = window.appDetailsStore.GetAppDetails(appid);
|
const details = window.appDetailsStore.GetAppDetails(appid);
|
||||||
console.log(details);
|
logger.debug('game details', details);
|
||||||
// strShortcutStartDir
|
// strShortcutStartDir
|
||||||
const file = await window.DeckyPluginLoader.openFilePicker(details.strShortcutStartDir.replaceAll('"', ''));
|
const file = await window.DeckyPluginLoader.openFilePicker(
|
||||||
console.log('user selected', file);
|
details?.strShortcutStartDir.replaceAll('"', '') || '/',
|
||||||
|
);
|
||||||
|
logger.debug('user selected', file);
|
||||||
window.SteamClient.Apps.SetShortcutExe(appid, JSON.stringify(file.path));
|
window.SteamClient.Apps.SetShortcutExe(appid, JSON.stringify(file.path));
|
||||||
const pathArr = file.path.split('/');
|
const pathArr = file.path.split('/');
|
||||||
pathArr.pop();
|
pathArr.pop();
|
||||||
const folder = pathArr.join('/');
|
const folder = pathArr.join('/');
|
||||||
window.SteamClient.Apps.SetShortcutStartDir(appid, JSON.stringify(folder));
|
window.SteamClient.Apps.SetShortcutStartDir(appid, JSON.stringify(folder));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
logger.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() {
|
export default async function libraryPatch() {
|
||||||
try {
|
try {
|
||||||
rePatch();
|
rePatch();
|
||||||
|
// TODO type and add to frontend-lib
|
||||||
|
let History: any;
|
||||||
|
|
||||||
|
while (!History) {
|
||||||
|
History = findModuleChild((m) => {
|
||||||
|
if (typeof m !== 'object') return undefined;
|
||||||
|
for (let prop in m) {
|
||||||
|
if (m[prop]?.m_history) return m[prop].m_history;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!History) {
|
||||||
|
logger.debug('Waiting 5s for history to become available.');
|
||||||
|
await sleep(5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const unlisten = History.listen(() => {
|
const unlisten = History.listen(() => {
|
||||||
if (window.SteamClient.Apps.PromptToChangeShortcut !== patch.patchedFunction) {
|
if (window.SteamClient.Apps.PromptToChangeShortcut !== patch.patchedFunction) {
|
||||||
rePatch();
|
rePatch();
|
||||||
@@ -47,11 +54,11 @@ export default async function libraryPatch() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
patch.unpatch();
|
|
||||||
unlisten();
|
unlisten();
|
||||||
|
patch.unpatch();
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error patching library file picker', e);
|
logger.error('Error patching library file picker', e);
|
||||||
}
|
}
|
||||||
return () => {};
|
return () => {};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { SidebarNavigation } from 'decky-frontend-lib';
|
import { SidebarNavigation } from 'decky-frontend-lib';
|
||||||
import { lazy } from 'react';
|
import { lazy } from 'react';
|
||||||
|
import { FaCode, FaPlug } from 'react-icons/fa';
|
||||||
|
|
||||||
import { useSetting } from '../../utils/hooks/useSetting';
|
import { useSetting } from '../../utils/hooks/useSetting';
|
||||||
|
import DeckyIcon from '../DeckyIcon';
|
||||||
import WithSuspense from '../WithSuspense';
|
import WithSuspense from '../WithSuspense';
|
||||||
import GeneralSettings from './pages/general';
|
import GeneralSettings from './pages/general';
|
||||||
import PluginList from './pages/plugin_list';
|
import PluginList from './pages/plugin_list';
|
||||||
@@ -13,19 +15,18 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
const pages = [
|
const pages = [
|
||||||
{
|
{
|
||||||
title: 'General',
|
title: 'Decky',
|
||||||
content: <GeneralSettings isDeveloper={isDeveloper} setIsDeveloper={setIsDeveloper} />,
|
content: <GeneralSettings isDeveloper={isDeveloper} setIsDeveloper={setIsDeveloper} />,
|
||||||
route: '/decky/settings/general',
|
route: '/decky/settings/general',
|
||||||
|
icon: <DeckyIcon />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Plugins',
|
title: 'Plugins',
|
||||||
content: <PluginList />,
|
content: <PluginList />,
|
||||||
route: '/decky/settings/plugins',
|
route: '/decky/settings/plugins',
|
||||||
|
icon: <FaPlug />,
|
||||||
},
|
},
|
||||||
];
|
{
|
||||||
|
|
||||||
if (isDeveloper)
|
|
||||||
pages.push({
|
|
||||||
title: 'Developer',
|
title: 'Developer',
|
||||||
content: (
|
content: (
|
||||||
<WithSuspense>
|
<WithSuspense>
|
||||||
@@ -33,7 +34,10 @@ export default function SettingsPage() {
|
|||||||
</WithSuspense>
|
</WithSuspense>
|
||||||
),
|
),
|
||||||
route: '/decky/settings/developer',
|
route: '/decky/settings/developer',
|
||||||
});
|
icon: <FaCode />,
|
||||||
|
visible: isDeveloper,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return <SidebarNavigation title="Decky Settings" showTitle pages={pages} />;
|
return <SidebarNavigation pages={pages} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +1,82 @@
|
|||||||
import { Field, Focusable, TextField, Toggle } from 'decky-frontend-lib';
|
import {
|
||||||
import { useRef } from 'react';
|
DialogBody,
|
||||||
import { FaReact, FaSteamSymbol } from 'react-icons/fa';
|
DialogButton,
|
||||||
|
DialogControlsSection,
|
||||||
|
DialogControlsSectionHeader,
|
||||||
|
Field,
|
||||||
|
TextField,
|
||||||
|
Toggle,
|
||||||
|
} from 'decky-frontend-lib';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { FaFileArchive, FaLink, FaReact, FaSteamSymbol } from 'react-icons/fa';
|
||||||
|
|
||||||
import { setShouldConnectToReactDevTools, setShowValveInternal } from '../../../../developer';
|
import { setShouldConnectToReactDevTools, setShowValveInternal } from '../../../../developer';
|
||||||
|
import { installFromURL } from '../../../../store';
|
||||||
import { useSetting } from '../../../../utils/hooks/useSetting';
|
import { useSetting } from '../../../../utils/hooks/useSetting';
|
||||||
|
import RemoteDebuggingSettings from '../general/RemoteDebugging';
|
||||||
|
|
||||||
|
const installFromZip = () => {
|
||||||
|
window.DeckyPluginLoader.openFilePicker('/home/deck', true).then((val) => {
|
||||||
|
const url = `file://${val.path}`;
|
||||||
|
console.log(`Installing plugin locally from ${url}`);
|
||||||
|
|
||||||
|
if (url.endsWith('.zip')) {
|
||||||
|
installFromURL(url);
|
||||||
|
} else {
|
||||||
|
window.DeckyPluginLoader.toaster.toast({
|
||||||
|
title: 'Decky',
|
||||||
|
body: `Installation failed! Only ZIP files are supported.`,
|
||||||
|
onClick: installFromZip,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export default function DeveloperSettings() {
|
export default function DeveloperSettings() {
|
||||||
const [enableValveInternal, setEnableValveInternal] = useSetting<boolean>('developer.valve_internal', false);
|
const [enableValveInternal, setEnableValveInternal] = useSetting<boolean>('developer.valve_internal', false);
|
||||||
const [reactDevtoolsEnabled, setReactDevtoolsEnabled] = useSetting<boolean>('developer.rdt.enabled', false);
|
const [reactDevtoolsEnabled, setReactDevtoolsEnabled] = useSetting<boolean>('developer.rdt.enabled', false);
|
||||||
const [reactDevtoolsIP, setReactDevtoolsIP] = useSetting<string>('developer.rdt.ip', '');
|
const [reactDevtoolsIP, setReactDevtoolsIP] = useSetting<string>('developer.rdt.ip', '');
|
||||||
|
const [pluginURL, setPluginURL] = useState('');
|
||||||
const textRef = useRef<HTMLDivElement>(null);
|
const textRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<DialogBody>
|
||||||
<Field
|
<DialogControlsSection>
|
||||||
label="Enable Valve Internal"
|
<DialogControlsSectionHeader>Third-Party Plugins</DialogControlsSectionHeader>
|
||||||
description={
|
<Field label="Install Plugin from ZIP File" icon={<FaFileArchive style={{ display: 'block' }} />}>
|
||||||
<span style={{ whiteSpace: 'pre-line' }}>
|
<DialogButton onClick={installFromZip}>Browse</DialogButton>
|
||||||
Enables the Valve internal developer menu.{' '}
|
</Field>
|
||||||
<span style={{ color: 'red' }}>Do not touch anything in this menu unless you know what it does.</span>
|
<Field
|
||||||
</span>
|
label="Install Plugin from URL"
|
||||||
}
|
description={<TextField label={'URL'} value={pluginURL} onChange={(e) => setPluginURL(e?.target.value)} />}
|
||||||
icon={<FaSteamSymbol style={{ display: 'block' }} />}
|
icon={<FaLink style={{ display: 'block' }} />}
|
||||||
>
|
>
|
||||||
<Toggle
|
<DialogButton disabled={pluginURL.length == 0} onClick={() => installFromURL(pluginURL)}>
|
||||||
value={enableValveInternal}
|
Install
|
||||||
onChange={(toggleValue) => {
|
</DialogButton>
|
||||||
setEnableValveInternal(toggleValue);
|
</Field>
|
||||||
setShowValveInternal(toggleValue);
|
</DialogControlsSection>
|
||||||
}}
|
<DialogControlsSection>
|
||||||
/>
|
<DialogControlsSectionHeader>Other</DialogControlsSectionHeader>
|
||||||
</Field>{' '}
|
<RemoteDebuggingSettings />
|
||||||
<Focusable
|
<Field
|
||||||
onTouchEnd={
|
label="Enable Valve Internal"
|
||||||
reactDevtoolsIP == ''
|
description={
|
||||||
? () => {
|
<span style={{ whiteSpace: 'pre-line' }}>
|
||||||
(textRef.current?.childNodes[0] as HTMLInputElement)?.focus();
|
Enables the Valve internal developer menu.{' '}
|
||||||
}
|
<span style={{ color: 'red' }}>Do not touch anything in this menu unless you know what it does.</span>
|
||||||
: undefined
|
</span>
|
||||||
}
|
}
|
||||||
onClick={
|
icon={<FaSteamSymbol style={{ display: 'block' }} />}
|
||||||
reactDevtoolsIP == ''
|
>
|
||||||
? () => {
|
<Toggle
|
||||||
(textRef.current?.childNodes[0] as HTMLInputElement)?.focus();
|
value={enableValveInternal}
|
||||||
}
|
onChange={(toggleValue) => {
|
||||||
: undefined
|
setEnableValveInternal(toggleValue);
|
||||||
}
|
setShowValveInternal(toggleValue);
|
||||||
onOKButton={
|
}}
|
||||||
reactDevtoolsIP == ''
|
/>
|
||||||
? () => {
|
</Field>
|
||||||
(textRef.current?.childNodes[0] as HTMLInputElement)?.focus();
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Field
|
<Field
|
||||||
label="Enable React DevTools"
|
label="Enable React DevTools"
|
||||||
description={
|
description={
|
||||||
@@ -62,6 +85,8 @@ export default function DeveloperSettings() {
|
|||||||
Enables connection to a computer running React DevTools. Changing this setting will reload Steam. Set
|
Enables connection to a computer running React DevTools. Changing this setting will reload Steam. Set
|
||||||
the IP address before enabling.
|
the IP address before enabling.
|
||||||
</span>
|
</span>
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
<div ref={textRef}>
|
<div ref={textRef}>
|
||||||
<TextField label={'IP'} value={reactDevtoolsIP} onChange={(e) => setReactDevtoolsIP(e?.target.value)} />
|
<TextField label={'IP'} value={reactDevtoolsIP} onChange={(e) => setReactDevtoolsIP(e?.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
@@ -71,14 +96,14 @@ export default function DeveloperSettings() {
|
|||||||
>
|
>
|
||||||
<Toggle
|
<Toggle
|
||||||
value={reactDevtoolsEnabled}
|
value={reactDevtoolsEnabled}
|
||||||
disabled={reactDevtoolsIP == ''}
|
// disabled={reactDevtoolsIP == ''}
|
||||||
onChange={(toggleValue) => {
|
onChange={(toggleValue) => {
|
||||||
setReactDevtoolsEnabled(toggleValue);
|
setReactDevtoolsEnabled(toggleValue);
|
||||||
setShouldConnectToReactDevTools(toggleValue);
|
setShouldConnectToReactDevTools(toggleValue);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</Focusable>
|
</DialogControlsSection>
|
||||||
</>
|
</DialogBody>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const BranchSelect: FunctionComponent<{}> = () => {
|
|||||||
return (
|
return (
|
||||||
// Returns numerical values from 0 to 2 (with current branch setup as of 8/28/22)
|
// 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
|
// 0 being stable, 1 being pre-release and 2 being nightly
|
||||||
<Field label="Update Channel">
|
<Field label="Decky Update Channel" childrenContainerWidth={'fixed'}>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
rgOptions={Object.values(UpdateBranch)
|
rgOptions={Object.values(UpdateBranch)
|
||||||
.filter((branch) => typeof branch == 'string')
|
.filter((branch) => typeof branch == 'string')
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Field, Toggle } from 'decky-frontend-lib';
|
import { Field, Toggle } from 'decky-frontend-lib';
|
||||||
import { FaBug } from 'react-icons/fa';
|
import { FaChrome } from 'react-icons/fa';
|
||||||
|
|
||||||
import { useSetting } from '../../../../utils/hooks/useSetting';
|
import { useSetting } from '../../../../utils/hooks/useSetting';
|
||||||
|
|
||||||
@@ -11,10 +11,10 @@ export default function RemoteDebuggingSettings() {
|
|||||||
label="Allow Remote CEF Debugging"
|
label="Allow Remote CEF Debugging"
|
||||||
description={
|
description={
|
||||||
<span style={{ whiteSpace: 'pre-line' }}>
|
<span style={{ whiteSpace: 'pre-line' }}>
|
||||||
Allow unauthenticated access to the CEF debugger to anyone in your network
|
Allows unauthenticated access to the CEF debugger to anyone in your network.
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
icon={<FaBug style={{ display: 'block' }} />}
|
icon={<FaChrome style={{ display: 'block' }} />}
|
||||||
>
|
>
|
||||||
<Toggle
|
<Toggle
|
||||||
value={allowRemoteDebugging || false}
|
value={allowRemoteDebugging || false}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const StoreSelect: FunctionComponent<{}> = () => {
|
|||||||
// 0 being Default, 1 being Testing and 2 being Custom
|
// 0 being Default, 1 being Testing and 2 being Custom
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Field label="Store Channel">
|
<Field label="Plugin Store Channel" childrenContainerWidth={'fixed'}>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
rgOptions={Object.values(Store)
|
rgOptions={Object.values(Store)
|
||||||
.filter((store) => typeof store == 'string')
|
.filter((store) => typeof store == 'string')
|
||||||
|
|||||||
@@ -6,15 +6,15 @@ import {
|
|||||||
Focusable,
|
Focusable,
|
||||||
ProgressBarWithInfo,
|
ProgressBarWithInfo,
|
||||||
Spinner,
|
Spinner,
|
||||||
|
findSP,
|
||||||
showModal,
|
showModal,
|
||||||
} from 'decky-frontend-lib';
|
} from 'decky-frontend-lib';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { Suspense, lazy } from 'react';
|
import { Suspense, lazy } from 'react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { FaArrowDown } from 'react-icons/fa';
|
import { FaExclamation } from 'react-icons/fa';
|
||||||
|
|
||||||
import { VerInfo, callUpdaterMethod, finishUpdate } from '../../../../updater';
|
import { VerInfo, callUpdaterMethod, finishUpdate } from '../../../../updater';
|
||||||
import { findSP } from '../../../../utils/windows';
|
|
||||||
import { useDeckyState } from '../../../DeckyState';
|
import { useDeckyState } from '../../../DeckyState';
|
||||||
import InlinePatchNotes from '../../../patchnotes/InlinePatchNotes';
|
import InlinePatchNotes from '../../../patchnotes/InlinePatchNotes';
|
||||||
import WithSuspense from '../../../WithSuspense';
|
import WithSuspense from '../../../WithSuspense';
|
||||||
@@ -39,7 +39,7 @@ function PatchNotesModal({ versionInfo, closeModal }: { versionInfo: VerInfo | n
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h1>{versionInfo?.all?.[id]?.name}</h1>
|
<h1>{versionInfo?.all?.[id]?.name || 'Invalid Update Name'}</h1>
|
||||||
{versionInfo?.all?.[id]?.body ? (
|
{versionInfo?.all?.[id]?.body ? (
|
||||||
<WithSuspense>
|
<WithSuspense>
|
||||||
<MarkdownRenderer onDismiss={closeModal}>{versionInfo.all[id].body}</MarkdownRenderer>
|
<MarkdownRenderer onDismiss={closeModal}>{versionInfo.all[id].body}</MarkdownRenderer>
|
||||||
@@ -95,21 +95,21 @@ export default function UpdaterSettings() {
|
|||||||
<Field
|
<Field
|
||||||
onOptionsActionDescription={versionInfo?.all ? 'Patch Notes' : undefined}
|
onOptionsActionDescription={versionInfo?.all ? 'Patch Notes' : undefined}
|
||||||
onOptionsButton={versionInfo?.all ? showPatchNotes : undefined}
|
onOptionsButton={versionInfo?.all ? showPatchNotes : undefined}
|
||||||
label="Updates"
|
label="Decky Updates"
|
||||||
description={
|
description={
|
||||||
versionInfo && (
|
checkingForUpdates || versionInfo?.remote?.tag_name != versionInfo?.current || !versionInfo?.remote ? (
|
||||||
<span style={{ whiteSpace: 'pre-line' }}>{`Current version: ${versionInfo.current}\n${
|
''
|
||||||
versionInfo.updatable ? `Latest version: ${versionInfo.remote?.tag_name}` : ''
|
) : (
|
||||||
}`}</span>
|
<span>Up to date: running {versionInfo?.current}</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
icon={
|
icon={
|
||||||
!versionInfo ? (
|
versionInfo?.remote &&
|
||||||
<Spinner style={{ width: '1em', height: 20, display: 'block' }} />
|
versionInfo?.remote?.tag_name != versionInfo?.current && (
|
||||||
) : (
|
<FaExclamation color="var(--gpColor-Yellow)" style={{ display: 'block' }} />
|
||||||
<FaArrowDown style={{ display: 'block' }} />
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
childrenContainerWidth={'fixed'}
|
||||||
>
|
>
|
||||||
{updateProgress == -1 && !isLoaderUpdating ? (
|
{updateProgress == -1 && !isLoaderUpdating ? (
|
||||||
<DialogButton
|
<DialogButton
|
||||||
@@ -144,7 +144,7 @@ export default function UpdaterSettings() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
{versionInfo?.remote && (
|
{versionInfo?.remote && versionInfo?.remote?.tag_name != versionInfo?.current && (
|
||||||
<InlinePatchNotes
|
<InlinePatchNotes
|
||||||
title={versionInfo?.remote.name}
|
title={versionInfo?.remote.name}
|
||||||
date={new Intl.RelativeTimeFormat('en-US', {
|
date={new Intl.RelativeTimeFormat('en-US', {
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { DialogButton, Field, TextField, Toggle } from 'decky-frontend-lib';
|
import { DialogBody, DialogControlsSection, DialogControlsSectionHeader, Field, Toggle } from 'decky-frontend-lib';
|
||||||
import { useState } from 'react';
|
|
||||||
import { FaShapes, FaTools } from 'react-icons/fa';
|
|
||||||
|
|
||||||
import { installFromURL } from '../../../../store';
|
import { useDeckyState } from '../../../DeckyState';
|
||||||
import BranchSelect from './BranchSelect';
|
import BranchSelect from './BranchSelect';
|
||||||
import RemoteDebuggingSettings from './RemoteDebugging';
|
|
||||||
import StoreSelect from './StoreSelect';
|
import StoreSelect from './StoreSelect';
|
||||||
import UpdaterSettings from './Updater';
|
import UpdaterSettings from './Updater';
|
||||||
|
|
||||||
@@ -15,35 +12,36 @@ export default function GeneralSettings({
|
|||||||
isDeveloper: boolean;
|
isDeveloper: boolean;
|
||||||
setIsDeveloper: (val: boolean) => void;
|
setIsDeveloper: (val: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const [pluginURL, setPluginURL] = useState('');
|
const { versionInfo } = useDeckyState();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<DialogBody>
|
||||||
<UpdaterSettings />
|
<DialogControlsSection>
|
||||||
<BranchSelect />
|
<DialogControlsSectionHeader>Updates</DialogControlsSectionHeader>
|
||||||
<StoreSelect />
|
<UpdaterSettings />
|
||||||
<RemoteDebuggingSettings />
|
</DialogControlsSection>
|
||||||
<Field
|
<DialogControlsSection>
|
||||||
label="Developer mode"
|
<DialogControlsSectionHeader>Beta Participation</DialogControlsSectionHeader>
|
||||||
description={<span style={{ whiteSpace: 'pre-line' }}>Enables Decky's developer settings.</span>}
|
<BranchSelect />
|
||||||
icon={<FaTools style={{ display: 'block' }} />}
|
<StoreSelect />
|
||||||
>
|
</DialogControlsSection>
|
||||||
<Toggle
|
<DialogControlsSection>
|
||||||
value={isDeveloper}
|
<DialogControlsSectionHeader>Other</DialogControlsSectionHeader>
|
||||||
onChange={(toggleValue) => {
|
<Field label="Enable Developer Mode">
|
||||||
setIsDeveloper(toggleValue);
|
<Toggle
|
||||||
}}
|
value={isDeveloper}
|
||||||
/>
|
onChange={(toggleValue) => {
|
||||||
</Field>
|
setIsDeveloper(toggleValue);
|
||||||
<Field
|
}}
|
||||||
label="Manual plugin install"
|
/>
|
||||||
description={<TextField label={'URL'} value={pluginURL} onChange={(e) => setPluginURL(e?.target.value)} />}
|
</Field>
|
||||||
icon={<FaShapes style={{ display: 'block' }} />}
|
</DialogControlsSection>
|
||||||
>
|
<DialogControlsSection>
|
||||||
<DialogButton disabled={pluginURL.length == 0} onClick={() => installFromURL(pluginURL)}>
|
<DialogControlsSectionHeader>About</DialogControlsSectionHeader>
|
||||||
Install
|
<Field label="Decky Version" focusable={true}>
|
||||||
</DialogButton>
|
<div style={{ color: 'var(--gpSystemLighterGrey)' }}>{versionInfo?.current}</div>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</DialogControlsSection>
|
||||||
|
</DialogBody>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,117 @@
|
|||||||
import { DialogButton, Focusable, Menu, MenuItem, showContextMenu } from 'decky-frontend-lib';
|
import {
|
||||||
import { useEffect } from 'react';
|
DialogBody,
|
||||||
import { FaDownload, FaEllipsisH } from 'react-icons/fa';
|
DialogButton,
|
||||||
|
DialogControlsSection,
|
||||||
|
GamepadEvent,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
ReorderableEntry,
|
||||||
|
ReorderableList,
|
||||||
|
showContextMenu,
|
||||||
|
} from 'decky-frontend-lib';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { FaDownload, FaEllipsisH, FaRecycle } from 'react-icons/fa';
|
||||||
|
|
||||||
import { requestPluginInstall } from '../../../../store';
|
import { StorePluginVersion, getPluginList, requestPluginInstall } from '../../../../store';
|
||||||
|
import { useSetting } from '../../../../utils/hooks/useSetting';
|
||||||
import { useDeckyState } from '../../../DeckyState';
|
import { useDeckyState } from '../../../DeckyState';
|
||||||
|
|
||||||
|
function labelToName(pluginLabel: string, pluginVersion?: string): string {
|
||||||
|
return pluginVersion ? pluginLabel.substring(0, pluginLabel.indexOf(` - ${pluginVersion}`)) : pluginLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reinstallPlugin(pluginName: string, currentVersion?: string) {
|
||||||
|
const serverData = await getPluginList();
|
||||||
|
const remotePlugin = serverData?.find((x) => x.name == pluginName);
|
||||||
|
if (remotePlugin && remotePlugin.versions?.length > 0) {
|
||||||
|
const currentVersionData = remotePlugin.versions.find((version) => version.name == currentVersion);
|
||||||
|
if (currentVersionData) requestPluginInstall(pluginName, currentVersionData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function PluginInteractables(props: { entry: ReorderableEntry<PluginData> }) {
|
||||||
|
const data = props.entry.data;
|
||||||
|
let pluginName = labelToName(props.entry.label, data?.version);
|
||||||
|
|
||||||
|
const showCtxMenu = (e: MouseEvent | GamepadEvent) => {
|
||||||
|
showContextMenu(
|
||||||
|
<Menu label="Plugin Actions">
|
||||||
|
<MenuItem onSelected={() => window.DeckyPluginLoader.importPlugin(pluginName, data?.version)}>Reload</MenuItem>
|
||||||
|
<MenuItem onSelected={() => window.DeckyPluginLoader.uninstallPlugin(pluginName)}>Uninstall</MenuItem>
|
||||||
|
</Menu>,
|
||||||
|
e.currentTarget ?? window,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{data?.update ? (
|
||||||
|
<DialogButton
|
||||||
|
style={{ height: '40px', minWidth: '60px', marginRight: '10px' }}
|
||||||
|
onClick={() => requestPluginInstall(pluginName, data?.update as StorePluginVersion)}
|
||||||
|
onOKButton={() => requestPluginInstall(pluginName, data?.update as StorePluginVersion)}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'row' }}>
|
||||||
|
Update to {data?.update?.name}
|
||||||
|
<FaDownload style={{ paddingLeft: '2rem' }} />
|
||||||
|
</div>
|
||||||
|
</DialogButton>
|
||||||
|
) : (
|
||||||
|
<DialogButton
|
||||||
|
style={{ height: '40px', minWidth: '60px', marginRight: '10px' }}
|
||||||
|
onClick={() => reinstallPlugin(pluginName, data?.version)}
|
||||||
|
onOKButton={() => reinstallPlugin(pluginName, data?.version)}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'row' }}>
|
||||||
|
Reinstall
|
||||||
|
<FaRecycle style={{ paddingLeft: '5.3rem' }} />
|
||||||
|
</div>
|
||||||
|
</DialogButton>
|
||||||
|
)}
|
||||||
|
<DialogButton
|
||||||
|
style={{ height: '40px', width: '40px', padding: '10px 12px', minWidth: '40px' }}
|
||||||
|
onClick={showCtxMenu}
|
||||||
|
onOKButton={showCtxMenu}
|
||||||
|
>
|
||||||
|
<FaEllipsisH />
|
||||||
|
</DialogButton>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type PluginData = {
|
||||||
|
update?: StorePluginVersion;
|
||||||
|
version?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export default function PluginList() {
|
export default function PluginList() {
|
||||||
const { plugins, updates } = useDeckyState();
|
const { plugins, updates, pluginOrder, setPluginOrder } = useDeckyState();
|
||||||
|
const [_, setPluginOrderSetting] = useSetting<string[]>(
|
||||||
|
'pluginOrder',
|
||||||
|
plugins.map((plugin) => plugin.name),
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.DeckyPluginLoader.checkPluginUpdates();
|
window.DeckyPluginLoader.checkPluginUpdates();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const [pluginEntries, setPluginEntries] = useState<ReorderableEntry<PluginData>[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPluginEntries(
|
||||||
|
plugins.map((plugin) => {
|
||||||
|
return {
|
||||||
|
label: plugin.version ? `${plugin.name} - ${plugin.version}` : plugin.name,
|
||||||
|
data: {
|
||||||
|
update: updates?.get(plugin.name),
|
||||||
|
version: plugin.version,
|
||||||
|
},
|
||||||
|
position: pluginOrder.indexOf(plugin.name),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [plugins, updates]);
|
||||||
|
|
||||||
if (plugins.length === 0) {
|
if (plugins.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -20,47 +120,18 @@ export default function PluginList() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onSave(entries: ReorderableEntry<PluginData>[]) {
|
||||||
|
const newOrder = entries.map((entry) => labelToName(entry.label, entry?.data?.version));
|
||||||
|
console.log(newOrder);
|
||||||
|
setPluginOrder(newOrder);
|
||||||
|
setPluginOrderSetting(newOrder);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul style={{ listStyleType: 'none' }}>
|
<DialogBody>
|
||||||
{plugins.map(({ name, version }) => {
|
<DialogControlsSection>
|
||||||
const update = updates?.get(name);
|
<ReorderableList<PluginData> entries={pluginEntries} onSave={onSave} interactables={PluginInteractables} />
|
||||||
return (
|
</DialogControlsSection>
|
||||||
<li style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', paddingBottom: '10px' }}>
|
</DialogBody>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
DialogButton,
|
ButtonItem,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
Focusable,
|
Focusable,
|
||||||
QuickAccessTab,
|
PanelSectionRow,
|
||||||
Router,
|
|
||||||
SingleDropdownOption,
|
SingleDropdownOption,
|
||||||
SuspensefulImage,
|
SuspensefulImage,
|
||||||
joinClassNames,
|
|
||||||
staticClasses,
|
|
||||||
} from 'decky-frontend-lib';
|
} from 'decky-frontend-lib';
|
||||||
import { FC, useRef, useState } from 'react';
|
import { FC, useState } from 'react';
|
||||||
|
|
||||||
import { StorePlugin, StorePluginVersion, requestPluginInstall } from '../../store';
|
import { StorePlugin, StorePluginVersion, requestPluginInstall } from '../../store';
|
||||||
|
|
||||||
@@ -19,168 +16,162 @@ interface PluginCardProps {
|
|||||||
|
|
||||||
const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
|
const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
|
||||||
const [selectedOption, setSelectedOption] = useState<number>(0);
|
const [selectedOption, setSelectedOption] = useState<number>(0);
|
||||||
const buttonRef = useRef<HTMLDivElement>(null);
|
const root: boolean = plugin.tags.some((tag) => tag === 'root');
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
className="deckyStoreCard"
|
||||||
style={{
|
style={{
|
||||||
padding: '30px',
|
marginLeft: '20px',
|
||||||
paddingTop: '10px',
|
marginRight: '20px',
|
||||||
paddingBottom: '10px',
|
marginBottom: '20px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* TODO: abstract this messy focus hackiness into a custom component in lib */}
|
<div
|
||||||
<Focusable
|
className="deckyStoreCardImageContainer"
|
||||||
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={{
|
style={{
|
||||||
display: 'flex',
|
width: '320px',
|
||||||
flexDirection: 'column',
|
height: '200px',
|
||||||
background: '#ACB2C924',
|
position: 'relative',
|
||||||
height: 'unset',
|
|
||||||
marginBottom: 'unset',
|
|
||||||
// boxShadow: var(--gpShadow-Medium);
|
|
||||||
scrollSnapAlign: 'start',
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="deckyStoreCardHeader" style={{ display: 'flex', alignItems: 'center' }}>
|
<SuspensefulImage
|
||||||
<div
|
className="deckyStoreCardImage"
|
||||||
style={{ fontSize: '18pt', padding: '10px' }}
|
suspenseHeight="200px"
|
||||||
className={joinClassNames(staticClasses.Text)}
|
suspenseWidth="320px"
|
||||||
// onClick={() => Router.NavigateToExternalWeb('https://github.com/' + plugin.artifact)}
|
|
||||||
>
|
|
||||||
{plugin.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
width: '320px',
|
||||||
flexDirection: 'row',
|
height: '200px',
|
||||||
|
objectFit: 'cover',
|
||||||
}}
|
}}
|
||||||
className="deckyStoreCardBody"
|
src={plugin.image_url}
|
||||||
>
|
/>
|
||||||
<SuspensefulImage
|
</div>
|
||||||
className="deckyStoreCardImage"
|
<div
|
||||||
suspenseWidth="256px"
|
className="deckyStoreCardInfo"
|
||||||
style={{
|
style={{
|
||||||
width: 'auto',
|
width: 'calc(100% - 320px)', // The calc is here so that the info section doesn't expand into the image
|
||||||
height: '160px',
|
display: 'flex',
|
||||||
}}
|
flexDirection: 'column',
|
||||||
src={plugin.image_url}
|
height: '100%',
|
||||||
/>
|
marginLeft: '1em',
|
||||||
<div
|
justifyContent: 'center',
|
||||||
style={{
|
}}
|
||||||
display: 'flex',
|
>
|
||||||
flexDirection: 'column',
|
<span
|
||||||
}}
|
className="deckyStoreCardTitle"
|
||||||
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>
|
|
||||||
))}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="deckyStoreCardActionsContainer"
|
|
||||||
style={{
|
style={{
|
||||||
|
fontSize: '1.25em',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
width: '90%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{plugin.name}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="deckyStoreCardAuthor"
|
||||||
|
style={{
|
||||||
|
marginRight: 'auto',
|
||||||
|
fontSize: '1em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{plugin.author}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="deckyStoreCardDescription"
|
||||||
|
style={{
|
||||||
|
fontSize: '13px',
|
||||||
|
color: '#969696',
|
||||||
|
WebkitLineClamp: root ? '2' : '3',
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: '-webkit-box',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{plugin.description ? (
|
||||||
|
plugin.description
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
<i style={{ color: '#666' }}>No description provided.</i>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{root && (
|
||||||
|
<span
|
||||||
|
className="deckyStoreCardDescription deckyStoreCardDescriptionRoot"
|
||||||
|
style={{
|
||||||
|
fontSize: '13px',
|
||||||
|
color: '#fee75c',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i>This plugin has full access to your Steam Deck.</i>{' '}
|
||||||
|
<a
|
||||||
|
className="deckyStoreCardDescriptionRootLink"
|
||||||
|
href="https://deckbrew.xyz/root"
|
||||||
|
target="_blank"
|
||||||
|
style={{
|
||||||
|
color: '#fee75c',
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
deckbrew.xyz/root
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="deckyStoreCardButtonRow"
|
||||||
|
style={{
|
||||||
|
marginTop: '1em',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
alignSelf: 'flex-end',
|
overflow: 'hidden',
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'row',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Focusable
|
<PanelSectionRow>
|
||||||
className="deckyStoreCardActions"
|
<Focusable style={{ display: 'flex', maxWidth: '100%' }}>
|
||||||
style={{
|
<div
|
||||||
display: 'flex',
|
className="deckyStoreCardInstallContainer"
|
||||||
flexDirection: 'row',
|
style={{
|
||||||
width: '100%',
|
paddingTop: '0px',
|
||||||
}}
|
paddingBottom: '0px',
|
||||||
>
|
width: '40%',
|
||||||
<div
|
}}
|
||||||
className="deckyStoreCardInstallButtonContainer"
|
|
||||||
style={{
|
|
||||||
flex: '1',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogButton
|
|
||||||
className="deckyStoreCardInstallButton"
|
|
||||||
ref={buttonRef}
|
|
||||||
onClick={() => requestPluginInstall(plugin.name, plugin.versions[selectedOption])}
|
|
||||||
>
|
>
|
||||||
Install
|
<ButtonItem
|
||||||
</DialogButton>
|
bottomSeparator="none"
|
||||||
</div>
|
layout="below"
|
||||||
<div
|
onClick={() => requestPluginInstall(plugin.name, plugin.versions[selectedOption])}
|
||||||
className="deckyStoreCardVersionDropdownContainer"
|
>
|
||||||
style={{
|
<span className="deckyStoreCardInstallText">Install</span>
|
||||||
flex: '0.2',
|
</ButtonItem>
|
||||||
}}
|
</div>
|
||||||
>
|
<div
|
||||||
<Dropdown
|
className="deckyStoreCardVersionContainer"
|
||||||
rgOptions={
|
style={{
|
||||||
plugin.versions.map((version: StorePluginVersion, index) => ({
|
marginLeft: '5%',
|
||||||
data: index,
|
width: '30%',
|
||||||
label: version.name,
|
}}
|
||||||
})) as SingleDropdownOption[]
|
>
|
||||||
}
|
<Dropdown
|
||||||
strDefaultLabel={'Select a version'}
|
rgOptions={
|
||||||
selectedOption={selectedOption}
|
plugin.versions.map((version: StorePluginVersion, index) => ({
|
||||||
onChange={({ data }) => setSelectedOption(data)}
|
data: index,
|
||||||
/>
|
label: version.name,
|
||||||
</div>
|
})) as SingleDropdownOption[]
|
||||||
</Focusable>
|
}
|
||||||
|
menuLabel="Plugin Version"
|
||||||
|
selectedOption={selectedOption}
|
||||||
|
onChange={({ data }) => setSelectedOption(data)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Focusable>
|
||||||
|
</PanelSectionRow>
|
||||||
</div>
|
</div>
|
||||||
</Focusable>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
import { SteamSpinner } from 'decky-frontend-lib';
|
import {
|
||||||
import { FC, useEffect, useState } from 'react';
|
Dropdown,
|
||||||
|
DropdownOption,
|
||||||
|
Focusable,
|
||||||
|
PanelSectionRow,
|
||||||
|
SteamSpinner,
|
||||||
|
Tabs,
|
||||||
|
TextField,
|
||||||
|
findModule,
|
||||||
|
} from 'decky-frontend-lib';
|
||||||
|
import { FC, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import logo from '../../../assets/plugin_store.png';
|
||||||
import Logger from '../../logger';
|
import Logger from '../../logger';
|
||||||
import { StorePlugin, getPluginList } from '../../store';
|
import { StorePlugin, getPluginList } from '../../store';
|
||||||
import PluginCard from './PluginCard';
|
import PluginCard from './PluginCard';
|
||||||
|
|
||||||
const logger = new Logger('FilePicker');
|
const logger = new Logger('Store');
|
||||||
|
|
||||||
const StorePage: FC<{}> = () => {
|
const StorePage: FC<{}> = () => {
|
||||||
|
const [currentTabRoute, setCurrentTabRoute] = useState<string>('browse');
|
||||||
const [data, setData] = useState<StorePlugin[] | null>(null);
|
const [data, setData] = useState<StorePlugin[] | null>(null);
|
||||||
|
const { TabCount } = findModule((m) => {
|
||||||
|
if (m?.TabCount && m?.TabTitle) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -19,19 +34,12 @@ const StorePage: FC<{}> = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
style={{
|
|
||||||
marginTop: '40px',
|
|
||||||
height: 'calc( 100% - 40px )',
|
|
||||||
overflowY: 'scroll',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
marginTop: '40px',
|
||||||
flexWrap: 'nowrap',
|
height: 'calc( 100% - 40px )',
|
||||||
flexDirection: 'column',
|
background: '#0005',
|
||||||
height: '100%',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!data ? (
|
{!data ? (
|
||||||
@@ -39,13 +47,193 @@ const StorePage: FC<{}> = () => {
|
|||||||
<SteamSpinner />
|
<SteamSpinner />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<Tabs
|
||||||
{data.map((plugin: StorePlugin) => (
|
activeTab={currentTabRoute}
|
||||||
<PluginCard plugin={plugin} />
|
onShowTab={(tabId: string) => {
|
||||||
))}
|
setCurrentTabRoute(tabId);
|
||||||
</div>
|
}}
|
||||||
|
tabs={[
|
||||||
|
{
|
||||||
|
title: 'Browse',
|
||||||
|
content: <BrowseTab children={{ data: data }} />,
|
||||||
|
id: 'browse',
|
||||||
|
renderTabAddon: () => <span className={TabCount}>{data.length}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'About',
|
||||||
|
content: <AboutTab />,
|
||||||
|
id: 'about',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const BrowseTab: FC<{ children: { data: StorePlugin[] } }> = (data) => {
|
||||||
|
const sortOptions = useMemo(
|
||||||
|
(): DropdownOption[] => [
|
||||||
|
{ data: 1, label: 'Alphabetical (A to Z)' },
|
||||||
|
{ data: 2, label: 'Alphabetical (Z to A)' },
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// const filterOptions = useMemo((): DropdownOption[] => [{ data: 1, label: 'All' }], []);
|
||||||
|
|
||||||
|
const [selectedSort, setSort] = useState<number>(sortOptions[0].data);
|
||||||
|
// const [selectedFilter, setFilter] = useState<number>(filterOptions[0].data);
|
||||||
|
const [searchFieldValue, setSearchValue] = useState<string>('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>{`
|
||||||
|
.deckyStoreCardInstallContainer > .Panel {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
{/* This should be used once filtering is added
|
||||||
|
|
||||||
|
<PanelSectionRow>
|
||||||
|
<Focusable style={{ display: 'flex', maxWidth: '100%' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
width: '47.5%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="DialogLabel">Sort</span>
|
||||||
|
<Dropdown
|
||||||
|
menuLabel="Sort"
|
||||||
|
rgOptions={sortOptions}
|
||||||
|
strDefaultLabel="Last Updated (Newest)"
|
||||||
|
selectedOption={selectedSort}
|
||||||
|
onChange={(e) => setSort(e.data)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
width: '47.5%',
|
||||||
|
marginLeft: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="DialogLabel">Filter</span>
|
||||||
|
<Dropdown
|
||||||
|
menuLabel="Filter"
|
||||||
|
rgOptions={filterOptions}
|
||||||
|
strDefaultLabel="All"
|
||||||
|
selectedOption={selectedFilter}
|
||||||
|
onChange={(e) => setFilter(e.data)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Focusable>
|
||||||
|
</PanelSectionRow>
|
||||||
|
<div style={{ justifyContent: 'center', display: 'flex' }}>
|
||||||
|
<Focusable style={{ display: 'flex', alignItems: 'center', width: '96%' }}>
|
||||||
|
<div style={{ width: '100%' }}>
|
||||||
|
<TextField label="Search" value={searchFieldValue} onChange={(e) => setSearchValue(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</Focusable>
|
||||||
|
</div>
|
||||||
|
*/}
|
||||||
|
<PanelSectionRow>
|
||||||
|
<Focusable style={{ display: 'flex', maxWidth: '100%' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
minWidth: '100%',
|
||||||
|
maxWidth: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="DialogLabel">Sort</span>
|
||||||
|
<Dropdown
|
||||||
|
menuLabel="Sort"
|
||||||
|
rgOptions={sortOptions}
|
||||||
|
strDefaultLabel="Last Updated (Newest)"
|
||||||
|
selectedOption={selectedSort}
|
||||||
|
onChange={(e) => setSort(e.data)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Focusable>
|
||||||
|
</PanelSectionRow>
|
||||||
|
<div style={{ justifyContent: 'center', display: 'flex' }}>
|
||||||
|
<Focusable style={{ display: 'flex', alignItems: 'center', width: '96%' }}>
|
||||||
|
<div style={{ width: '100%' }}>
|
||||||
|
<TextField label="Search" value={searchFieldValue} onChange={(e) => setSearchValue(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</Focusable>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{data.children.data
|
||||||
|
.filter((plugin: StorePlugin) => {
|
||||||
|
return (
|
||||||
|
plugin.name.toLowerCase().includes(searchFieldValue.toLowerCase()) ||
|
||||||
|
plugin.description.toLowerCase().includes(searchFieldValue.toLowerCase()) ||
|
||||||
|
plugin.author.toLowerCase().includes(searchFieldValue.toLowerCase()) ||
|
||||||
|
plugin.tags.some((tag: string) => tag.toLowerCase().includes(searchFieldValue.toLowerCase()))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (selectedSort % 2 === 1) return a.name.localeCompare(b.name);
|
||||||
|
else return b.name.localeCompare(a.name);
|
||||||
|
})
|
||||||
|
.map((plugin: StorePlugin) => (
|
||||||
|
<PluginCard plugin={plugin} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AboutTab: FC<{}> = () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<style>{`
|
||||||
|
.deckyStoreAboutHeader {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<img
|
||||||
|
src={logo}
|
||||||
|
style={{
|
||||||
|
width: '256px',
|
||||||
|
height: 'auto',
|
||||||
|
alignSelf: 'center',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="deckyStoreAboutHeader">Testing</span>
|
||||||
|
<span>
|
||||||
|
Please consider testing new plugins to help the Decky Loader team!{' '}
|
||||||
|
<a
|
||||||
|
href="https://deckbrew.xyz/testing"
|
||||||
|
target="_blank"
|
||||||
|
style={{
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
deckbrew.xyz/testing
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
<span className="deckyStoreAboutHeader">Contributing</span>
|
||||||
|
<span>
|
||||||
|
If you would like to contribute to the Decky Plugin Store, check the SteamDeckHomebrew/decky-plugin-template
|
||||||
|
repository on GitHub. Information on development and distribution is available in the README.
|
||||||
|
</span>
|
||||||
|
<span className="deckyStoreAboutHeader">Source Code</span>
|
||||||
|
<span>All plugin source code is available on SteamDeckHomebrew/decky-plugin-database repository on GitHub.</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
Navigation,
|
||||||
ReactRouter,
|
ReactRouter,
|
||||||
Router,
|
Router,
|
||||||
fakeRenderComponent,
|
fakeRenderComponent,
|
||||||
@@ -26,13 +27,20 @@ const logger = new Logger('DeveloperMode');
|
|||||||
|
|
||||||
let removeSettingsObserver: () => void = () => {};
|
let removeSettingsObserver: () => void = () => {};
|
||||||
|
|
||||||
export function setShowValveInternal(show: boolean) {
|
export async function setShowValveInternal(show: boolean) {
|
||||||
const settingsMod = findModuleChild((m) => {
|
let settingsMod: any;
|
||||||
if (typeof m !== 'object') return undefined;
|
while (!settingsMod) {
|
||||||
for (let prop in m) {
|
settingsMod = findModuleChild((m) => {
|
||||||
if (typeof m[prop]?.settings?.bIsValveEmail !== 'undefined') return m[prop];
|
if (typeof m !== 'object') return undefined;
|
||||||
|
for (let prop in m) {
|
||||||
|
if (typeof m[prop]?.settings?.bIsValveEmail !== 'undefined') return m[prop];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!settingsMod) {
|
||||||
|
logger.debug('[ValveInternal] waiting for settingsMod');
|
||||||
|
await sleep(1000);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
if (show) {
|
if (show) {
|
||||||
removeSettingsObserver = settingsMod[
|
removeSettingsObserver = settingsMod[
|
||||||
@@ -74,13 +82,14 @@ export async function startup() {
|
|||||||
window.DFL = {
|
window.DFL = {
|
||||||
findModuleChild,
|
findModuleChild,
|
||||||
findModule,
|
findModule,
|
||||||
|
Navigation,
|
||||||
|
Router,
|
||||||
|
ReactRouter,
|
||||||
ReactUtils: {
|
ReactUtils: {
|
||||||
fakeRenderComponent,
|
fakeRenderComponent,
|
||||||
findInReactTree,
|
findInReactTree,
|
||||||
findInTree,
|
findInTree,
|
||||||
},
|
},
|
||||||
Router,
|
|
||||||
ReactRouter,
|
|
||||||
classes: {
|
classes: {
|
||||||
scrollClasses,
|
scrollClasses,
|
||||||
staticClasses,
|
staticClasses,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { Navigation, Router, sleep } from 'decky-frontend-lib';
|
||||||
|
|
||||||
import PluginLoader from './plugin-loader';
|
import PluginLoader from './plugin-loader';
|
||||||
import { DeckyUpdater } from './updater';
|
import { DeckyUpdater } from './updater';
|
||||||
|
|
||||||
@@ -14,6 +16,23 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
if (!Router.NavigateToAppProperties || !Router.NavigateToLibraryTab || !Router.NavigateToInvites) {
|
||||||
|
while (!Navigation.NavigateToAppProperties) await sleep(100);
|
||||||
|
const shims = {
|
||||||
|
NavigateToAppProperties: Navigation.NavigateToAppProperties,
|
||||||
|
NavigateToInvites: Navigation.NavigateToInvites,
|
||||||
|
NavigateToLibraryTab: Navigation.NavigateToLibraryTab,
|
||||||
|
};
|
||||||
|
(Router as unknown as any).deckyShim = true;
|
||||||
|
Object.assign(Router, shims);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[DECKY]: Error initializing Navigation interface shims', e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
window.deckyAuthToken = await fetch('http://127.0.0.1:1337/auth/token').then((r) => r.text());
|
window.deckyAuthToken = await fetch('http://127.0.0.1:1337/auth/token').then((r) => r.text());
|
||||||
|
|
||||||
@@ -37,6 +56,7 @@ declare global {
|
|||||||
if (!window.DeckyPluginLoader.hasPlugin(plugin.name))
|
if (!window.DeckyPluginLoader.hasPlugin(plugin.name))
|
||||||
window.DeckyPluginLoader?.importPlugin(plugin.name, plugin.version);
|
window.DeckyPluginLoader?.importPlugin(plugin.name, plugin.version);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.DeckyPluginLoader.checkPluginUpdates();
|
window.DeckyPluginLoader.checkPluginUpdates();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,4 @@
|
|||||||
import {
|
import { ConfirmModal, ModalRoot, Patch, QuickAccessTab, Router, showModal, sleep } from 'decky-frontend-lib';
|
||||||
ConfirmModal,
|
|
||||||
ModalRoot,
|
|
||||||
Patch,
|
|
||||||
QuickAccessTab,
|
|
||||||
Router,
|
|
||||||
showModal,
|
|
||||||
sleep,
|
|
||||||
staticClasses,
|
|
||||||
} from 'decky-frontend-lib';
|
|
||||||
import { FC, lazy } from 'react';
|
import { FC, lazy } from 'react';
|
||||||
import { FaCog, FaExclamationCircle, FaPlug } from 'react-icons/fa';
|
import { FaCog, FaExclamationCircle, FaPlug } from 'react-icons/fa';
|
||||||
|
|
||||||
@@ -21,6 +12,7 @@ import WithSuspense from './components/WithSuspense';
|
|||||||
import Logger from './logger';
|
import Logger from './logger';
|
||||||
import { Plugin } from './plugin';
|
import { Plugin } from './plugin';
|
||||||
import RouterHook from './router-hook';
|
import RouterHook from './router-hook';
|
||||||
|
import { deinitSteamFixes, initSteamFixes } from './steamfixes';
|
||||||
import { checkForUpdates } from './store';
|
import { checkForUpdates } from './store';
|
||||||
import TabsHook from './tabs-hook';
|
import TabsHook from './tabs-hook';
|
||||||
import OldTabsHook from './tabs-hook.old';
|
import OldTabsHook from './tabs-hook.old';
|
||||||
@@ -33,10 +25,6 @@ const SettingsPage = lazy(() => import('./components/settings'));
|
|||||||
|
|
||||||
const FilePicker = lazy(() => import('./components/modals/filepicker'));
|
const FilePicker = lazy(() => import('./components/modals/filepicker'));
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {}
|
|
||||||
}
|
|
||||||
|
|
||||||
class PluginLoader extends Logger {
|
class PluginLoader extends Logger {
|
||||||
private plugins: Plugin[] = [];
|
private plugins: Plugin[] = [];
|
||||||
private tabsHook: TabsHook | OldTabsHook = document.title == 'SP' ? new OldTabsHook() : new TabsHook();
|
private tabsHook: TabsHook | OldTabsHook = document.title == 'SP' ? new OldTabsHook() : new TabsHook();
|
||||||
@@ -92,6 +80,8 @@ class PluginLoader extends Logger {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
initSteamFixes();
|
||||||
|
|
||||||
initFilepickerPatches();
|
initFilepickerPatches();
|
||||||
|
|
||||||
this.updateVersion();
|
this.updateVersion();
|
||||||
@@ -156,10 +146,10 @@ class PluginLoader extends Logger {
|
|||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
// do nothing
|
// do nothing
|
||||||
}}
|
}}
|
||||||
|
strTitle={`Uninstall ${name}`}
|
||||||
|
strOKButtonText={'Uninstall'}
|
||||||
>
|
>
|
||||||
<div className={staticClasses.Title} style={{ flexDirection: 'column' }}>
|
Are you sure you want to uninstall {name}?
|
||||||
Uninstall {name}?
|
|
||||||
</div>
|
|
||||||
</ConfirmModal>,
|
</ConfirmModal>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -179,16 +169,24 @@ class PluginLoader extends Logger {
|
|||||||
getSetting('developer.enabled', false).then((val) => {
|
getSetting('developer.enabled', false).then((val) => {
|
||||||
if (val) import('./developer').then((developer) => developer.startup());
|
if (val) import('./developer').then((developer) => developer.startup());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
//* Grab and set plugin order
|
||||||
|
getSetting<string[]>('pluginOrder', []).then((pluginOrder) => {
|
||||||
|
console.log(pluginOrder);
|
||||||
|
this.deckyState.setPluginOrder(pluginOrder);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public deinit() {
|
public deinit() {
|
||||||
this.routerHook.removeRoute('/decky/store');
|
this.routerHook.removeRoute('/decky/store');
|
||||||
this.routerHook.removeRoute('/decky/settings');
|
this.routerHook.removeRoute('/decky/settings');
|
||||||
|
deinitSteamFixes();
|
||||||
deinitFilepickerPatches();
|
deinitFilepickerPatches();
|
||||||
this.focusWorkaroundPatch?.unpatch();
|
this.focusWorkaroundPatch?.unpatch();
|
||||||
}
|
}
|
||||||
|
|
||||||
public unloadPlugin(name: string) {
|
public unloadPlugin(name: string) {
|
||||||
|
console.log('Plugin List: ', this.plugins);
|
||||||
const plugin = this.plugins.find((plugin) => plugin.name === name || plugin.name === name.replace('$LEGACY_', ''));
|
const plugin = this.plugins.find((plugin) => plugin.name === name || plugin.name === name.replace('$LEGACY_', ''));
|
||||||
plugin?.onDismount?.();
|
plugin?.onDismount?.();
|
||||||
this.plugins = this.plugins.filter((p) => p !== plugin);
|
this.plugins = this.plugins.filter((p) => p !== plugin);
|
||||||
@@ -344,6 +342,7 @@ class PluginLoader extends Logger {
|
|||||||
fetchNoCors(url: string, request: any = {}) {
|
fetchNoCors(url: string, request: any = {}) {
|
||||||
let args = { method: 'POST', headers: {} };
|
let args = { method: 'POST', headers: {} };
|
||||||
const req = { ...args, ...request, url, data: request.body };
|
const req = { ...args, ...request, url, data: request.body };
|
||||||
|
req?.body && delete req.body;
|
||||||
return this.callServerMethod('http_request', req);
|
return this.callServerMethod('http_request', req);
|
||||||
},
|
},
|
||||||
executeInTab(tab: string, runAsync: boolean, code: string) {
|
executeInTab(tab: string, runAsync: boolean, code: string) {
|
||||||
|
|||||||
@@ -123,11 +123,9 @@ class RouterHook extends Logger {
|
|||||||
this.wrapperPatch = afterPatch(this.gamepadWrapper, 'render', (_: any, ret: any) => {
|
this.wrapperPatch = afterPatch(this.gamepadWrapper, 'render', (_: any, ret: any) => {
|
||||||
if (ret?.props?.children?.props?.children?.length == 5 || ret?.props?.children?.props?.children?.length == 4) {
|
if (ret?.props?.children?.props?.children?.length == 5 || ret?.props?.children?.props?.children?.length == 4) {
|
||||||
const idx = ret?.props?.children?.props?.children?.length == 4 ? 1 : 2;
|
const idx = ret?.props?.children?.props?.children?.length == 4 ? 1 : 2;
|
||||||
if (
|
const potentialSettingsRootString =
|
||||||
ret.props.children.props.children[idx]?.props?.children?.[0]?.type?.type
|
ret.props.children.props.children[idx]?.props?.children?.[0]?.type?.type?.toString() || '';
|
||||||
?.toString()
|
if (potentialSettingsRootString?.includes('Settings.Root()')) {
|
||||||
?.includes('GamepadUI.Settings.Root()')
|
|
||||||
) {
|
|
||||||
if (!this.router) {
|
if (!this.router) {
|
||||||
this.router = ret.props.children.props.children[idx]?.props?.children?.[0]?.type;
|
this.router = ret.props.children.props.children[idx]?.props?.children?.[0]?.type;
|
||||||
this.routerPatch = afterPatch(this.router, 'type', (_: any, ret: any) => {
|
this.routerPatch = afterPatch(this.router, 'type', (_: any, ret: any) => {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
## What's this?
|
||||||
|
|
||||||
|
`steamfixes` contains various fixes and workaround for things Valve has broken that cause Decky issues.
|
||||||
|
|
||||||
|
## Current fixes:
|
||||||
|
|
||||||
|
- StartRestart() -> StartShutdown(false) override:
|
||||||
|
|
||||||
|
StartRestart() breaks CEF debugging, StartShutdown(false) doesn't. We can safely replace StartRestart() with StartShutdown(false) as gamescope-session will automatically restart the steam client anyway if it shuts down, bypassing the broken restart codepath. Added 12/29/2022
|
||||||
|
|
||||||
|
- ExecuteSteamURL UI reload fix:
|
||||||
|
|
||||||
|
Starting sometime in November 2022, Valve broke reloading the Steam UI pages via location.reload, as it won't properly start the UI. We can manually trigger UI startup if we detect no active input contexts by calling `SteamClient.URL.ExecuteSteamURL("steam://open/settings/")` Added 12/29/2022
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import reloadFix from './reload';
|
||||||
|
import restartFix from './restart';
|
||||||
|
let fixes: Function[] = [];
|
||||||
|
|
||||||
|
export function deinitSteamFixes() {
|
||||||
|
fixes.forEach((deinit) => deinit());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initSteamFixes() {
|
||||||
|
fixes.push(await reloadFix());
|
||||||
|
fixes.push(await restartFix());
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { getFocusNavController, sleep } from 'decky-frontend-lib';
|
||||||
|
|
||||||
|
import Logger from '../logger';
|
||||||
|
|
||||||
|
const logger = new Logger('ReloadSteamFix');
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
var GamepadNavTree: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function reloadFix() {
|
||||||
|
// Hack to unbreak the ui when reloading it
|
||||||
|
await sleep(4000);
|
||||||
|
if (getFocusNavController()?.m_rgAllContexts?.length == 0) {
|
||||||
|
SteamClient.URL.ExecuteSteamURL('steam://open/settings');
|
||||||
|
logger.log('Applied UI reload fix.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// This steamfix does not need to deinit.
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Patch, findModuleChild, replacePatch, sleep } from 'decky-frontend-lib';
|
||||||
|
|
||||||
|
import Logger from '../logger';
|
||||||
|
|
||||||
|
const logger = new Logger('RestartSteamFix');
|
||||||
|
|
||||||
|
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 with History.listen or an interval
|
||||||
|
patch = replacePatch(window.SteamClient.User, 'StartRestart', () => SteamClient.User.StartShutdown(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function restartFix() {
|
||||||
|
try {
|
||||||
|
rePatch();
|
||||||
|
// TODO type and add to frontend-lib
|
||||||
|
let History: any;
|
||||||
|
|
||||||
|
while (!History) {
|
||||||
|
History = findModuleChild((m) => {
|
||||||
|
if (typeof m !== 'object') return undefined;
|
||||||
|
for (let prop in m) {
|
||||||
|
if (m[prop]?.m_history) return m[prop].m_history;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!History) {
|
||||||
|
logger.debug('Waiting 5s for history to become available.');
|
||||||
|
await sleep(5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function repatchIfNeeded() {
|
||||||
|
if (window.SteamClient.User.StartRestart !== patch.patchedFunction) {
|
||||||
|
rePatch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unlisten = History.listen(repatchIfNeeded);
|
||||||
|
|
||||||
|
// Just in case
|
||||||
|
setTimeout(repatchIfNeeded, 5000);
|
||||||
|
setTimeout(repatchIfNeeded, 10000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unlisten();
|
||||||
|
patch.unpatch();
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Error patching StartRestart', e);
|
||||||
|
}
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ export enum Store {
|
|||||||
export interface StorePluginVersion {
|
export interface StorePluginVersion {
|
||||||
name: string;
|
name: string;
|
||||||
hash: string;
|
hash: string;
|
||||||
|
artifact: string | undefined | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StorePlugin {
|
export interface StorePlugin {
|
||||||
@@ -73,9 +74,11 @@ export async function installFromURL(url: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function requestPluginInstall(plugin: string, selectedVer: StorePluginVersion) {
|
export async function requestPluginInstall(plugin: string, selectedVer: StorePluginVersion) {
|
||||||
|
const artifactUrl =
|
||||||
|
selectedVer.artifact ?? `https://cdn.tzatzikiweeb.moe/file/steam-deck-homebrew/versions/${selectedVer.hash}.zip`;
|
||||||
await window.DeckyPluginLoader.callServerMethod('install_plugin', {
|
await window.DeckyPluginLoader.callServerMethod('install_plugin', {
|
||||||
name: plugin,
|
name: plugin,
|
||||||
artifact: `https://cdn.tzatzikiweeb.moe/file/steam-deck-homebrew/versions/${selectedVer.hash}.zip`,
|
artifact: artifactUrl,
|
||||||
version: selectedVer.name,
|
version: selectedVer.name,
|
||||||
hash: selectedVer.hash,
|
hash: selectedVer.hash,
|
||||||
});
|
});
|
||||||
|
|||||||
+10
-12
@@ -7,7 +7,6 @@ import Logger from './logger';
|
|||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__TABS_HOOK_INSTANCE: any;
|
__TABS_HOOK_INSTANCE: any;
|
||||||
securitystore: any;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,7 +22,6 @@ class TabsHook extends Logger {
|
|||||||
tabs: Tab[] = [];
|
tabs: Tab[] = [];
|
||||||
private qAMRoot?: any;
|
private qAMRoot?: any;
|
||||||
private qamPatch?: Patch;
|
private qamPatch?: Patch;
|
||||||
private unsubscribeSecurity?: () => void;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('TabsHook');
|
super('TabsHook');
|
||||||
@@ -37,7 +35,7 @@ class TabsHook extends Logger {
|
|||||||
const tree = (document.getElementById('root') as any)._reactRootContainer._internalRoot.current;
|
const tree = (document.getElementById('root') as any)._reactRootContainer._internalRoot.current;
|
||||||
let qAMRoot: any;
|
let qAMRoot: any;
|
||||||
const findQAMRoot = (currentNode: any, iters: number): any => {
|
const findQAMRoot = (currentNode: any, iters: number): any => {
|
||||||
if (iters >= 55) {
|
if (iters >= 65) {
|
||||||
// currently 45
|
// currently 45
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -114,7 +112,6 @@ class TabsHook extends Logger {
|
|||||||
deinit() {
|
deinit() {
|
||||||
this.qamPatch?.unpatch();
|
this.qamPatch?.unpatch();
|
||||||
this.qAMRoot.return.alternate.type = this.qAMRoot.return.type;
|
this.qAMRoot.return.alternate.type = this.qAMRoot.return.type;
|
||||||
this.unsubscribeSecurity?.();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
add(tab: Tab) {
|
add(tab: Tab) {
|
||||||
@@ -131,22 +128,23 @@ class TabsHook extends Logger {
|
|||||||
let deckyTabAmount = existingTabs.reduce((prev: any, cur: any) => (cur.decky ? prev + 1 : prev), 0);
|
let deckyTabAmount = existingTabs.reduce((prev: any, cur: any) => (cur.decky ? prev + 1 : prev), 0);
|
||||||
if (deckyTabAmount == this.tabs.length) {
|
if (deckyTabAmount == this.tabs.length) {
|
||||||
for (let tab of existingTabs) {
|
for (let tab of existingTabs) {
|
||||||
if (tab?.decky) tab.panel.props.setter[0](visible);
|
if (tab?.decky && tab?.qAMVisibilitySetter) tab?.qAMVisibilitySetter(visible);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const { title, icon, content, id } of this.tabs) {
|
for (const { title, icon, content, id } of this.tabs) {
|
||||||
existingTabs.push({
|
const tab: any = {
|
||||||
key: id,
|
key: id,
|
||||||
title,
|
title,
|
||||||
tab: icon,
|
tab: icon,
|
||||||
decky: true,
|
decky: true,
|
||||||
panel: (
|
};
|
||||||
<QuickAccessVisibleStateProvider initial={visible} setter={[]}>
|
tab.panel = (
|
||||||
{content}
|
<QuickAccessVisibleStateProvider initial={visible} tab={tab}>
|
||||||
</QuickAccessVisibleStateProvider>
|
{content}
|
||||||
),
|
</QuickAccessVisibleStateProvider>
|
||||||
});
|
);
|
||||||
|
existingTabs.push(tab);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-11
@@ -1,4 +1,4 @@
|
|||||||
import { Patch, ToastData, afterPatch, findInReactTree, sleep } from 'decky-frontend-lib';
|
import { Module, Patch, ToastData, afterPatch, findInReactTree, findModuleChild, sleep } from 'decky-frontend-lib';
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
import Toast from './components/Toast';
|
import Toast from './components/Toast';
|
||||||
@@ -7,6 +7,7 @@ import Logger from './logger';
|
|||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__TOASTER_INSTANCE: any;
|
__TOASTER_INSTANCE: any;
|
||||||
|
settingsStore: any;
|
||||||
NotificationStore: any;
|
NotificationStore: any;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,7 +17,7 @@ class Toaster extends Logger {
|
|||||||
// private toasterState: DeckyToasterState = new DeckyToasterState();
|
// private toasterState: DeckyToasterState = new DeckyToasterState();
|
||||||
private node: any;
|
private node: any;
|
||||||
private rNode: any;
|
private rNode: any;
|
||||||
private settingsModule: any;
|
private audioModule: any;
|
||||||
private finishStartup?: () => void;
|
private finishStartup?: () => void;
|
||||||
private ready: Promise<void> = new Promise((res) => (this.finishStartup = res));
|
private ready: Promise<void> = new Promise((res) => (this.finishStartup = res));
|
||||||
private toasterPatch?: Patch;
|
private toasterPatch?: Patch;
|
||||||
@@ -39,11 +40,15 @@ class Toaster extends Logger {
|
|||||||
let instance: any;
|
let instance: any;
|
||||||
const tree = (document.getElementById('root') as any)._reactRootContainer._internalRoot.current;
|
const tree = (document.getElementById('root') as any)._reactRootContainer._internalRoot.current;
|
||||||
const findToasterRoot = (currentNode: any, iters: number): any => {
|
const findToasterRoot = (currentNode: any, iters: number): any => {
|
||||||
if (iters >= 50) {
|
if (iters >= 65) {
|
||||||
// currently 40
|
// currently 65
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (currentNode?.memoizedProps?.className?.startsWith?.('toastmanager_ToastPlaceholder')) {
|
if (
|
||||||
|
currentNode?.memoizedProps?.className?.startsWith?.('gamepadtoasts_GamepadToastPlaceholder') ||
|
||||||
|
currentNode?.memoizedProps?.className?.startsWith?.('toastmanager_ToastPlaceholder') ||
|
||||||
|
currentNode?.memoizedProps?.className?.startsWith?.('toastmanager_ToastPopup')
|
||||||
|
) {
|
||||||
this.log(`Toaster root was found in ${iters} recursion cycles`);
|
this.log(`Toaster root was found in ${iters} recursion cycles`);
|
||||||
return currentNode;
|
return currentNode;
|
||||||
}
|
}
|
||||||
@@ -127,6 +132,17 @@ class Toaster extends Logger {
|
|||||||
this.rNode.stateNode.forceUpdate();
|
this.rNode.stateNode.forceUpdate();
|
||||||
delete this.rNode.stateNode.shouldComponentUpdate;
|
delete this.rNode.stateNode.shouldComponentUpdate;
|
||||||
|
|
||||||
|
this.audioModule = findModuleChild((m: Module) => {
|
||||||
|
if (typeof m !== 'object') return undefined;
|
||||||
|
for (let prop in m) {
|
||||||
|
try {
|
||||||
|
if (m[prop].PlayNavSound && m[prop].RegisterCallbackOnPlaySound) return m[prop];
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.log('Initialized');
|
this.log('Initialized');
|
||||||
this.finishStartup?.();
|
this.finishStartup?.();
|
||||||
}
|
}
|
||||||
@@ -135,24 +151,31 @@ class Toaster extends Logger {
|
|||||||
// toast.duration = toast.duration || 5e3;
|
// toast.duration = toast.duration || 5e3;
|
||||||
// this.toasterState.addToast(toast);
|
// this.toasterState.addToast(toast);
|
||||||
await this.ready;
|
await this.ready;
|
||||||
const settings = this.settingsModule?.settings;
|
|
||||||
let toastData = {
|
let toastData = {
|
||||||
nNotificationID: window.NotificationStore.m_nNextTestNotificationID++,
|
nNotificationID: window.NotificationStore.m_nNextTestNotificationID++,
|
||||||
rtCreated: Date.now(),
|
rtCreated: Date.now(),
|
||||||
eType: 15,
|
eType: toast.eType || 11,
|
||||||
nToastDurationMS: toast.duration || (toast.duration = 5e3),
|
nToastDurationMS: toast.duration || (toast.duration = 5e3),
|
||||||
data: toast,
|
data: toast,
|
||||||
decky: true,
|
decky: true,
|
||||||
};
|
};
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
toastData.data.appid = () => 0;
|
toastData.data.appid = () => 0;
|
||||||
|
if (toast.sound === undefined) toast.sound = 6;
|
||||||
|
if (toast.playSound === undefined) toast.playSound = true;
|
||||||
|
if (toast.showToast === undefined) toast.showToast = true;
|
||||||
if (
|
if (
|
||||||
(settings?.bDisableAllToasts && !toast.critical) ||
|
(window.settingsStore.settings.bDisableAllToasts && !toast.critical) ||
|
||||||
(settings?.bDisableToastsInGame && !toast.critical && window.NotificationStore.BIsUserInGame())
|
(window.settingsStore.settings.bDisableToastsInGame &&
|
||||||
|
!toast.critical &&
|
||||||
|
window.NotificationStore.BIsUserInGame())
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
window.NotificationStore.m_rgNotificationToasts.push(toastData);
|
if (toast.playSound) this.audioModule?.PlayNavSound(toast.sound);
|
||||||
window.NotificationStore.DispatchNextToast();
|
if (toast.showToast) {
|
||||||
|
window.NotificationStore.m_rgNotificationToasts.push(toastData);
|
||||||
|
window.NotificationStore.DispatchNextToast();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit() {
|
deinit() {
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
export function findSP(): Window {
|
|
||||||
// old (SP as host)
|
|
||||||
if (document.title == 'SP') return window;
|
|
||||||
// new (SP as popup)
|
|
||||||
return FocusNavController.m_ActiveContext.m_rgGamepadNavigationTrees.find((x: any) => x.m_ID == 'root_1_').Root
|
|
||||||
.Element.ownerDocument.defaultView;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"target": "ES2020",
|
"target": "ES2021",
|
||||||
"jsx": "react",
|
"jsx": "react",
|
||||||
"jsxFactory": "window.SP_REACT.createElement",
|
"jsxFactory": "window.SP_REACT.createElement",
|
||||||
"jsxFragmentFactory": "window.SP_REACT.Fragment",
|
"jsxFragmentFactory": "window.SP_REACT.Fragment",
|
||||||
@@ -18,6 +18,6 @@
|
|||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src", "index.d.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"""
|
||||||
|
This module exposes various constants and helpers useful for decky plugins.
|
||||||
|
|
||||||
|
* Plugin's settings and configurations should be stored under `DECKY_PLUGIN_SETTINGS_DIR`.
|
||||||
|
* Plugin's runtime data should be stored under `DECKY_PLUGIN_RUNTIME_DIR`.
|
||||||
|
* Plugin's persistent log files should be stored under `DECKY_PLUGIN_LOG_DIR`.
|
||||||
|
|
||||||
|
Avoid writing outside of `DECKY_HOME`, storing under the suggested paths is strongly recommended.
|
||||||
|
|
||||||
|
Some basic migration helpers are available: `migrate_any`, `migrate_settings`, `migrate_runtime`, `migrate_logs`.
|
||||||
|
|
||||||
|
A logging facility `logger` is available which writes to the recommended location.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = '0.1.0'
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import logging
|
||||||
|
|
||||||
|
"""
|
||||||
|
Constants
|
||||||
|
"""
|
||||||
|
|
||||||
|
HOME: str = os.getenv("HOME", default="")
|
||||||
|
"""
|
||||||
|
The home directory of the effective user running the process.
|
||||||
|
Environment variable: `HOME`.
|
||||||
|
If `root` was specified in the plugin's flags it will be `/root` otherwise the user whose home decky resides in.
|
||||||
|
e.g.: `/home/deck`
|
||||||
|
"""
|
||||||
|
|
||||||
|
USER: str = os.getenv("USER", default="")
|
||||||
|
"""
|
||||||
|
The effective username running the process.
|
||||||
|
Environment variable: `USER`.
|
||||||
|
It would be `root` if `root` was specified in the plugin's flags otherwise the user whose home decky resides in.
|
||||||
|
e.g.: `deck`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_VERSION: str = os.getenv("DECKY_VERSION", default="")
|
||||||
|
"""
|
||||||
|
The version of the decky loader.
|
||||||
|
Environment variable: `DECKY_VERSION`.
|
||||||
|
e.g.: `v2.5.0-pre1`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_USER: str = os.getenv("DECKY_USER", default="")
|
||||||
|
"""
|
||||||
|
The user whose home decky resides in.
|
||||||
|
Environment variable: `DECKY_USER`.
|
||||||
|
e.g.: `deck`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_USER_HOME: str = os.getenv("DECKY_USER_HOME", default="")
|
||||||
|
"""
|
||||||
|
The home of the user where decky resides in.
|
||||||
|
Environment variable: `DECKY_USER_HOME`.
|
||||||
|
e.g.: `/home/deck`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_HOME: str = os.getenv("DECKY_HOME", default="")
|
||||||
|
"""
|
||||||
|
The root of the decky folder.
|
||||||
|
Environment variable: `DECKY_HOME`.
|
||||||
|
e.g.: `/home/deck/homebrew`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_PLUGIN_SETTINGS_DIR: str = os.getenv(
|
||||||
|
"DECKY_PLUGIN_SETTINGS_DIR", default="")
|
||||||
|
"""
|
||||||
|
The recommended path in which to store configuration files (created automatically).
|
||||||
|
Environment variable: `DECKY_PLUGIN_SETTINGS_DIR`.
|
||||||
|
e.g.: `/home/deck/homebrew/settings/decky-plugin-template`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_PLUGIN_RUNTIME_DIR: str = os.getenv(
|
||||||
|
"DECKY_PLUGIN_RUNTIME_DIR", default="")
|
||||||
|
"""
|
||||||
|
The recommended path in which to store runtime data (created automatically).
|
||||||
|
Environment variable: `DECKY_PLUGIN_RUNTIME_DIR`.
|
||||||
|
e.g.: `/home/deck/homebrew/data/decky-plugin-template`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_PLUGIN_LOG_DIR: str = os.getenv("DECKY_PLUGIN_LOG_DIR", default="")
|
||||||
|
"""
|
||||||
|
The recommended path in which to store persistent logs (created automatically).
|
||||||
|
Environment variable: `DECKY_PLUGIN_LOG_DIR`.
|
||||||
|
e.g.: `/home/deck/homebrew/logs/decky-plugin-template`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_PLUGIN_DIR: str = os.getenv("DECKY_PLUGIN_DIR", default="")
|
||||||
|
"""
|
||||||
|
The root of the plugin's directory.
|
||||||
|
Environment variable: `DECKY_PLUGIN_DIR`.
|
||||||
|
e.g.: `/home/deck/homebrew/plugins/decky-plugin-template`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_PLUGIN_NAME: str = os.getenv("DECKY_PLUGIN_NAME", default="")
|
||||||
|
"""
|
||||||
|
The name of the plugin as specified in the 'plugin.json'.
|
||||||
|
Environment variable: `DECKY_PLUGIN_NAME`.
|
||||||
|
e.g.: `Example Plugin`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_PLUGIN_VERSION: str = os.getenv("DECKY_PLUGIN_VERSION", default="")
|
||||||
|
"""
|
||||||
|
The version of the plugin as specified in the 'package.json'.
|
||||||
|
Environment variable: `DECKY_PLUGIN_VERSION`.
|
||||||
|
e.g.: `0.0.1`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_PLUGIN_AUTHOR: str = os.getenv("DECKY_PLUGIN_AUTHOR", default="")
|
||||||
|
"""
|
||||||
|
The author of the plugin as specified in the 'plugin.json'.
|
||||||
|
Environment variable: `DECKY_PLUGIN_AUTHOR`.
|
||||||
|
e.g.: `John Doe`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_PLUGIN_LOG: str = os.path.join(DECKY_PLUGIN_LOG_DIR, "plugin.log")
|
||||||
|
"""
|
||||||
|
The path to the plugin's main logfile.
|
||||||
|
Environment variable: `DECKY_PLUGIN_LOG`.
|
||||||
|
e.g.: `/home/deck/homebrew/logs/decky-plugin-template/plugin.log`
|
||||||
|
"""
|
||||||
|
|
||||||
|
"""
|
||||||
|
Migration helpers
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_any(target_dir: str, *files_or_directories: str) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Migrate files and directories to a new location and remove old locations.
|
||||||
|
Specified files will be migrated to `target_dir`.
|
||||||
|
Specified directories will have their contents recursively migrated to `target_dir`.
|
||||||
|
|
||||||
|
Returns the mapping of old -> new location.
|
||||||
|
"""
|
||||||
|
file_map: dict[str, str] = {}
|
||||||
|
for f in files_or_directories:
|
||||||
|
if not os.path.exists(f):
|
||||||
|
file_map[f] = ""
|
||||||
|
continue
|
||||||
|
if os.path.isdir(f):
|
||||||
|
src_dir = f
|
||||||
|
src_file = "."
|
||||||
|
file_map[f] = target_dir
|
||||||
|
else:
|
||||||
|
src_dir = os.path.dirname(f)
|
||||||
|
src_file = os.path.basename(f)
|
||||||
|
file_map[f] = os.path.join(target_dir, src_file)
|
||||||
|
subprocess.run(["sh", "-c", "mkdir -p \"$3\"; tar -cf - -C \"$1\" \"$2\" | tar -xf - -C \"$3\" && rm -rf \"$4\"",
|
||||||
|
"_", src_dir, src_file, target_dir, f])
|
||||||
|
return file_map
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_settings(*files_or_directories: str) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Migrate files and directories relating to plugin settings to the recommended location and remove old locations.
|
||||||
|
Specified files will be migrated to `DECKY_PLUGIN_SETTINGS_DIR`.
|
||||||
|
Specified directories will have their contents recursively migrated to `DECKY_PLUGIN_SETTINGS_DIR`.
|
||||||
|
|
||||||
|
Returns the mapping of old -> new location.
|
||||||
|
"""
|
||||||
|
return migrate_any(DECKY_PLUGIN_SETTINGS_DIR, *files_or_directories)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_runtime(*files_or_directories: str) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Migrate files and directories relating to plugin runtime data to the recommended location and remove old locations
|
||||||
|
Specified files will be migrated to `DECKY_PLUGIN_RUNTIME_DIR`.
|
||||||
|
Specified directories will have their contents recursively migrated to `DECKY_PLUGIN_RUNTIME_DIR`.
|
||||||
|
|
||||||
|
Returns the mapping of old -> new location.
|
||||||
|
"""
|
||||||
|
return migrate_any(DECKY_PLUGIN_RUNTIME_DIR, *files_or_directories)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_logs(*files_or_directories: str) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Migrate files and directories relating to plugin logs to the recommended location and remove old locations.
|
||||||
|
Specified files will be migrated to `DECKY_PLUGIN_LOG_DIR`.
|
||||||
|
Specified directories will have their contents recursively migrated to `DECKY_PLUGIN_LOG_DIR`.
|
||||||
|
|
||||||
|
Returns the mapping of old -> new location.
|
||||||
|
"""
|
||||||
|
return migrate_any(DECKY_PLUGIN_LOG_DIR, *files_or_directories)
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
Logging
|
||||||
|
"""
|
||||||
|
|
||||||
|
logging.basicConfig(filename=DECKY_PLUGIN_LOG,
|
||||||
|
format='[%(asctime)s][%(levelname)s]: %(message)s',
|
||||||
|
force=True)
|
||||||
|
logger: logging.Logger = logging.getLogger()
|
||||||
|
"""The main plugin logger writing to `DECKY_PLUGIN_LOG`."""
|
||||||
|
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""
|
||||||
|
This module exposes various constants and helpers useful for decky plugins.
|
||||||
|
|
||||||
|
* Plugin's settings and configurations should be stored under `DECKY_PLUGIN_SETTINGS_DIR`.
|
||||||
|
* Plugin's runtime data should be stored under `DECKY_PLUGIN_RUNTIME_DIR`.
|
||||||
|
* Plugin's persistent log files should be stored under `DECKY_PLUGIN_LOG_DIR`.
|
||||||
|
|
||||||
|
Avoid writing outside of `DECKY_HOME`, storing under the suggested paths is strongly recommended.
|
||||||
|
|
||||||
|
Some basic migration helpers are available: `migrate_any`, `migrate_settings`, `migrate_runtime`, `migrate_logs`.
|
||||||
|
|
||||||
|
A logging facility `logger` is available which writes to the recommended location.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = '0.1.0'
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
"""
|
||||||
|
Constants
|
||||||
|
"""
|
||||||
|
|
||||||
|
HOME: str
|
||||||
|
"""
|
||||||
|
The home directory of the effective user running the process.
|
||||||
|
Environment variable: `HOME`.
|
||||||
|
If `root` was specified in the plugin's flags it will be `/root` otherwise the user whose home decky resides in.
|
||||||
|
e.g.: `/home/deck`
|
||||||
|
"""
|
||||||
|
|
||||||
|
USER: str
|
||||||
|
"""
|
||||||
|
The effective username running the process.
|
||||||
|
Environment variable: `USER`.
|
||||||
|
It would be `root` if `root` was specified in the plugin's flags otherwise the user whose home decky resides in.
|
||||||
|
e.g.: `deck`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_VERSION: str
|
||||||
|
"""
|
||||||
|
The version of the decky loader.
|
||||||
|
Environment variable: `DECKY_VERSION`.
|
||||||
|
e.g.: `v2.5.0-pre1`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_USER: str
|
||||||
|
"""
|
||||||
|
The user whose home decky resides in.
|
||||||
|
Environment variable: `DECKY_USER`.
|
||||||
|
e.g.: `deck`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_USER_HOME: str
|
||||||
|
"""
|
||||||
|
The home of the user where decky resides in.
|
||||||
|
Environment variable: `DECKY_USER_HOME`.
|
||||||
|
e.g.: `/home/deck`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_HOME: str
|
||||||
|
"""
|
||||||
|
The root of the decky folder.
|
||||||
|
Environment variable: `DECKY_HOME`.
|
||||||
|
e.g.: `/home/deck/homebrew`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_PLUGIN_SETTINGS_DIR: str
|
||||||
|
"""
|
||||||
|
The recommended path in which to store configuration files (created automatically).
|
||||||
|
Environment variable: `DECKY_PLUGIN_SETTINGS_DIR`.
|
||||||
|
e.g.: `/home/deck/homebrew/settings/decky-plugin-template`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_PLUGIN_RUNTIME_DIR: str
|
||||||
|
"""
|
||||||
|
The recommended path in which to store runtime data (created automatically).
|
||||||
|
Environment variable: `DECKY_PLUGIN_RUNTIME_DIR`.
|
||||||
|
e.g.: `/home/deck/homebrew/data/decky-plugin-template`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_PLUGIN_LOG_DIR: str
|
||||||
|
"""
|
||||||
|
The recommended path in which to store persistent logs (created automatically).
|
||||||
|
Environment variable: `DECKY_PLUGIN_LOG_DIR`.
|
||||||
|
e.g.: `/home/deck/homebrew/logs/decky-plugin-template`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_PLUGIN_DIR: str
|
||||||
|
"""
|
||||||
|
The root of the plugin's directory.
|
||||||
|
Environment variable: `DECKY_PLUGIN_DIR`.
|
||||||
|
e.g.: `/home/deck/homebrew/plugins/decky-plugin-template`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_PLUGIN_NAME: str
|
||||||
|
"""
|
||||||
|
The name of the plugin as specified in the 'plugin.json'.
|
||||||
|
Environment variable: `DECKY_PLUGIN_NAME`.
|
||||||
|
e.g.: `Example Plugin`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_PLUGIN_VERSION: str
|
||||||
|
"""
|
||||||
|
The version of the plugin as specified in the 'package.json'.
|
||||||
|
Environment variable: `DECKY_PLUGIN_VERSION`.
|
||||||
|
e.g.: `0.0.1`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_PLUGIN_AUTHOR: str
|
||||||
|
"""
|
||||||
|
The author of the plugin as specified in the 'plugin.json'.
|
||||||
|
Environment variable: `DECKY_PLUGIN_AUTHOR`.
|
||||||
|
e.g.: `John Doe`
|
||||||
|
"""
|
||||||
|
|
||||||
|
DECKY_PLUGIN_LOG: str
|
||||||
|
"""
|
||||||
|
The path to the plugin's main logfile.
|
||||||
|
Environment variable: `DECKY_PLUGIN_LOG`.
|
||||||
|
e.g.: `/home/deck/homebrew/logs/decky-plugin-template/plugin.log`
|
||||||
|
"""
|
||||||
|
|
||||||
|
"""
|
||||||
|
Migration helpers
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_any(target_dir: str, *files_or_directories: str) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Migrate files and directories to a new location and remove old locations.
|
||||||
|
Specified files will be migrated to `target_dir`.
|
||||||
|
Specified directories will have their contents recursively migrated to `target_dir`.
|
||||||
|
|
||||||
|
Returns the mapping of old -> new location.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_settings(*files_or_directories: str) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Migrate files and directories relating to plugin settings to the recommended location and remove old locations.
|
||||||
|
Specified files will be migrated to `DECKY_PLUGIN_SETTINGS_DIR`.
|
||||||
|
Specified directories will have their contents recursively migrated to `DECKY_PLUGIN_SETTINGS_DIR`.
|
||||||
|
|
||||||
|
Returns the mapping of old -> new location.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_runtime(*files_or_directories: str) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Migrate files and directories relating to plugin runtime data to the recommended location and remove old locations
|
||||||
|
Specified files will be migrated to `DECKY_PLUGIN_RUNTIME_DIR`.
|
||||||
|
Specified directories will have their contents recursively migrated to `DECKY_PLUGIN_RUNTIME_DIR`.
|
||||||
|
|
||||||
|
Returns the mapping of old -> new location.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_logs(*files_or_directories: str) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Migrate files and directories relating to plugin logs to the recommended location and remove old locations.
|
||||||
|
Specified files will be migrated to `DECKY_PLUGIN_LOG_DIR`.
|
||||||
|
Specified directories will have their contents recursively migrated to `DECKY_PLUGIN_LOG_DIR`.
|
||||||
|
|
||||||
|
Returns the mapping of old -> new location.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
Logging
|
||||||
|
"""
|
||||||
|
|
||||||
|
logger: logging.Logger
|
||||||
|
"""The main plugin logger writing to `DECKY_PLUGIN_LOG`."""
|
||||||
+1
-1
@@ -2,4 +2,4 @@ aiohttp==3.8.1
|
|||||||
aiohttp-jinja2==1.5.0
|
aiohttp-jinja2==1.5.0
|
||||||
aiohttp_cors==0.7.0
|
aiohttp_cors==0.7.0
|
||||||
watchdog==2.1.7
|
watchdog==2.1.7
|
||||||
certifi==2022.6.15
|
certifi==2022.12.7
|
||||||
Reference in New Issue
Block a user