Compare commits

..

17 Commits

Author SHA1 Message Date
Sky Leite f1db083749 Merge branch 'main' into python-lint-format-test 2023-02-04 00:42:38 -03:00
Sky Leite 897e1773a5 Disable SyntaxError lint rule due to ruff not supporting pattern matching 2023-02-04 00:31:42 -03:00
Sky Leite 4e92d4bfc5 Fix unescaped curly braces in javascript formatting
This would previously throw an error due to `format` interpreting the
curly braces as placeholders
2023-02-04 00:29:34 -03:00
Sky Leite 1edcc09020 Fix F821 lint rule 2023-02-04 00:22:17 -03:00
Sky Leite e3e1cf2df7 Fix F811 linter rule
get_user_group was defined twice, so I merged both definitions
2023-02-04 00:21:31 -03:00
Sky Leite 93587fe33b Fix E722 linter rule 2023-02-04 00:12:00 -03:00
Sky Leite 8fab487153 Add python linting actions 2023-02-04 00:06:53 -03:00
Sky Leite b6fce46081 Fix most formatting and linting errors 2023-02-03 23:59:43 -03:00
Sky Leite e20fd5042c Revert "Test that prettier properly fails the lint job"
This reverts commit 1f596e5a10.
2023-02-03 10:36:05 -03:00
Sky Leite 1f596e5a10 Test that prettier properly fails the lint job 2023-02-03 10:34:50 -03:00
Sky Leite 6edc3bb658 Remove popd 2023-02-03 10:31:07 -03:00
Sky Leite b33d44c53d Remove Python and Shell linters 2023-02-03 10:29:40 -03:00
Sky Leite 1379a40a89 Move linters to separate workflow 2023-02-03 10:07:16 -03:00
Sky Leite d48fc885a3 Use lint script from package.json 2023-02-03 09:51:28 -03:00
Sky Leite 7675775527 Install prettier plugins before linting 2023-02-02 18:59:23 -03:00
Sky Leite 1320b13507 Add prettier-plugin-import-sort 2023-02-02 18:50:33 -03:00
Sky Leite 52777bc2a4 Add lint job to build workflow 2023-02-02 18:46:57 -03:00
51 changed files with 1942 additions and 2806 deletions
+1 -2
View File
@@ -12,7 +12,6 @@ body:
- label: I have searched existing issues - label: I have searched existing issues
- label: This issue is not a duplicate of an existing one - 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 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 - type: textarea
attributes: attributes:
@@ -71,4 +70,4 @@ body:
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 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 placeholder: deckylog.txt
validations: validations:
required: true required: false
+1 -44
View File
@@ -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 --add-data ./plugin:/plugin ./backend/*.py run: pyinstaller --noconfirm --onefile --name "PluginLoader" --add-data ./backend/static:/static --add-data ./backend/legacy:/legacy ./backend/*.py
- name: Upload package artifact ⬆️ - name: Upload package artifact ⬆️
if: ${{ !env.ACT }} if: ${{ !env.ACT }}
@@ -84,49 +84,6 @@ 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' }}
-55
View File
@@ -1,55 +0,0 @@
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'
+10
View File
@@ -15,3 +15,13 @@ jobs:
pushd frontend pushd frontend
npm install npm install
npm run lint npm run lint
- name: Run black (Python formatting)
uses: lgeiger/black-action@v1.0.1
with:
args: "./backend --experimental-string-processing --config ./backend/pyproject.toml"
- name: Run ruff (Python linting)
uses: jpetrucciani/ruff-check@main
with:
path: "./backend"
+1 -1
View File
@@ -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"
} }
+13 -13
View File
@@ -3,7 +3,7 @@
<br> <br>
Decky Loader Decky Loader
<br> <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> <a name="logo" href="https://github.com/SteamDeckHomebrew/decky-installer/releases/latest/download/decky_installer.desktop"><img src="./docs/images/download_button.png" alt="Download decky" width="350"></a>
</h1> </h1>
<p align="center"> <p align="center">
@@ -11,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://deckbrew.xyz/discord"><img src="https://img.shields.io/discord/960281551428522045?color=%235865F2&label=discord" /></a> <a href="https://discord.gg/ZU74G2NJzk"><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%">
@@ -33,39 +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). - If you run the installer and it just opens a file in a text editor: click the (...) button in the top right of dolphin (the file manager) then 'configure' and 'configure dolphin'. Click on the 'confirmations' tab and set 'when opening an executable file' to 'run script'.
## 💾 Installation ## 💾 Installation
- This installation can be done without an admin/sudo password set. - This installation can be done without an admin/sudo password set.
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. (KDE connect is preinstalled on the steam deck) - Many Bluetooth keyboard and mouse apps are available for iOS and Android.
- The Steam Link app is available on [Windows](https://media.steampowered.com/steamlink/windows/latest/SteamLink.zip), [macOS](https://apps.apple.com/us/app/steam-link/id1246969117), and [Linux](https://flathub.org/apps/details/com.valvesoftware.SteamLink). It works well as a remote desktop substitute. - 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. Navigate to this Github page on a browser of your choice. 1. Navigate to this Github page on a browser of your choice.
1. Download the [installer file](https://github.com/SteamDeckHomebrew/decky-installer/releases/latest/download/decky_installer.desktop). 1. Press the 'Download' button at the top of the page.
1. Drag the file onto your desktop and double click it to run it. 1. Run the downloaded file by clicking on it in Dolphin (the file manager).
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. Either type your admin password or allow Decky to temporarily set your password to `Decky!`
1. Choose the version of Decky Loader you want to install. 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.
- **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://wiki.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://deckbrew.xyz/en/loader-dev/development).
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. - 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://deckbrew.xyz/discord) 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://discord.gg/ZU74G2NJzk) so we can help you and other users.
1. Press the <img src="./docs/images/light/steam.svg#gh-dark-mode-only" height=16><img src="./docs/images/dark/steam.svg#gh-light-mode-only" height=16> button and open the Power menu. 1. 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. Run the installer file again, and select `uninstall decky loader`. 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. - 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
@@ -83,16 +84,15 @@ 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://deckbrew.xyz/discord) 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://discord.gg/ZU74G2NJzk) if you have any questions.
### 🤝 Contributing ### 🤝 Contributing
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). Please consult [the wiki page regarding development](https://deckbrew.xyz/en/loader-dev/development) for more information on installing development versions of Decky Loader. You can also install the Steam Deck UI on a Windows or Linux computer for testing by following [this YouTube guide](https://youtu.be/1IAbZte8e7E?t=112).
1. Clone the repository using the latest commit to main before starting your PR. 1. 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
``` ```
+121 -104
View File
@@ -1,27 +1,33 @@
# Full imports # Full imports
import json import json
# import pprint # import pprint
# from pprint import pformat # from pprint import pformat
# Partial imports # Partial imports
from aiohttp import ClientSession, web from aiohttp import ClientSession
from asyncio import get_event_loop, sleep from asyncio import sleep
from concurrent.futures import ProcessPoolExecutor
from hashlib import sha256 from hashlib import sha256
from io import BytesIO 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, 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, download_remote_binary_to_path from helpers import (
get_ssl_context,
get_user,
get_user_group,
download_remote_binary_to_path,
)
from injector import get_gamepadui_tab from injector import get_gamepadui_tab
logger = getLogger("Browser") logger = getLogger("Browser")
class PluginInstallContext: class PluginInstallContext:
def __init__(self, artifact, name, version, hash) -> None: def __init__(self, artifact, name, version, hash) -> None:
self.artifact = artifact self.artifact = artifact
@@ -29,12 +35,12 @@ class PluginInstallContext:
self.version = version self.version = version
self.hash = hash self.hash = hash
class PluginBrowser: class PluginBrowser:
def __init__(self, plugin_path, plugins, loader, settings) -> None: def __init__(self, plugin_path, plugins, loader) -> 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):
@@ -43,32 +49,41 @@ 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 = path.join(self.plugin_path, self.find_plugin_folder(name)) plugin_dir = self.find_plugin_folder(name)
code_chown = call(
if not chown(plugin_dir) or not chmod(plugin_dir, 555): ["chown", "-R", get_user() + ":" + get_user_group(), plugin_dir]
logger.error(f"chown/chmod exited with a non-zero exit code") )
code_chmod = call(["chmod", "-R", "555", plugin_dir])
if code_chown != 0 or code_chmod != 0:
logger.error(
f"chown/chmod exited with a non-zero exit code (chown: {code_chown},"
f" chmod: {code_chmod})"
)
return False return False
return True return True
async def _download_remote_binaries_for_plugin_with_name(self, pluginBasePath): async def _download_remote_binaries_for_plugin_with_name(self, pluginBasePath):
rv = False rv = False
try: try:
packageJsonPath = path.join(pluginBasePath, 'package.json') packageJsonPath = path.join(pluginBasePath, "package.json")
pluginBinPath = path.join(pluginBasePath, 'bin') pluginBinPath = path.join(pluginBasePath, "bin")
if access(packageJsonPath, R_OK): if access(packageJsonPath, R_OK):
with open(packageJsonPath, "r", encoding="utf-8") 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.
chmod(pluginBasePath, 777) call(["chmod", "-R", "777", pluginBasePath])
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):
chmod(pluginBinPath, 777) call(["chmod", "-R", "777", pluginBinPath])
rv = True rv = True
for remoteBinary in packageJson["remote_binary"]: for remoteBinary in packageJson["remote_binary"]:
@@ -76,73 +91,82 @@ class PluginBrowser:
binName = remoteBinary["name"] binName = remoteBinary["name"]
binURL = remoteBinary["url"] binURL = remoteBinary["url"]
binHash = remoteBinary["sha256hash"] binHash = remoteBinary["sha256hash"]
if not await download_remote_binary_to_path(binURL, binHash, path.join(pluginBinPath, binName)): if not await download_remote_binary_to_path(
binURL, binHash, path.join(pluginBinPath, binName)
):
rv = False rv = False
raise Exception(f"Error Downloading Remote Binary {binName}@{binURL} with hash {binHash} to {path.join(pluginBinPath, binName)}") raise Exception(
"Error Downloading Remote Binary"
f" {binName}@{binURL} with hash {binHash} to"
f" {path.join(pluginBinPath, binName)}"
)
chown(self.plugin_path) call(
chmod(pluginBasePath, 555) [
"chown",
"-R",
get_user() + ":" + get_user_group(),
self.plugin_path,
]
)
call(["chmod", "-R", "555", pluginBasePath])
else: else:
rv = True rv = True
logger.debug(f"No Remote Binaries to Download") logger.debug("No Remote Binaries to Download")
except Exception as e: except Exception as e:
rv = False rv = False
logger.debug(str(e)) logger.debug(str(e))
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", encoding="utf-8") 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 folder return str(path.join(self.plugin_path, folder))
except: except Exception:
logger.debug(f"skipping {folder}") logger.debug(f"skipping {folder}")
async def uninstall_plugin(self, name): async def uninstall_plugin(self, name):
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 " + plugin_dir) logger.info(" at dir " + self.find_plugin_folder(name))
logger.debug("calling frontend unload for %s" % str(name)) logger.debug("calling frontend unload for %s" % str(name))
res = await tab.evaluate_js(f"DeckyPluginLoader.unloadPlugin('{name}')") res = await tab.evaluate_js(f"DeckyPluginLoader.unloadPlugin('{name}')")
logger.debug("result of unload from UI: %s", res) logger.debug("result of unload from UI: %s", res)
# plugins_snapshot = self.plugins.copy() # plugins_snapshot = self.plugins.copy()
# snapshot_string = pformat(plugins_snapshot) # snapshot_string = pformat(plugins_snapshot)
# logger.debug("current plugins: %s", snapshot_string) # logger.debug("current plugins: %s", snapshot_string)
if name in self.plugins: if self.plugins[name]:
logger.debug("Plugin %s was found", name) logger.debug("Plugin %s was found", name)
self.plugins[name].stop() self.plugins[name].stop()
logger.debug("Plugin %s was stopped", name) 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) 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(plugin_dir) rmtree(self.find_plugin_folder(name))
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 {plugin_dir} was not uninstalled") logger.error(
logger.error(f"Error at %s", exc_info=e) f"Plugin {name} in {self.find_plugin_folder(name)} was not uninstalled"
)
logger.error("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
@@ -150,75 +174,68 @@ class PluginBrowser:
pluginFolderPath = self.find_plugin_folder(name) pluginFolderPath = self.find_plugin_folder(name)
if pluginFolderPath: if pluginFolderPath:
isInstalled = True isInstalled = True
except: except Exception:
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"
# Check if the file is a local file or a URL " anyway."
if artifact.startswith("file://"): )
logger.info(f"Installing {name} from local ZIP file (Version: {version})") logger.info(f"Installing {name} (Version: {version})")
res_zip = BytesIO(open(artifact[7:], "rb").read()) async with ClientSession() as client:
else: logger.debug(f"Fetching {artifact}")
logger.info(f"Installing {name} from URL (Version: {version})") res = await client.get(artifact, ssl=get_ssl_context())
async with ClientSession() as client: if res.status == 200:
logger.debug(f"Fetching {artifact}") logger.debug("Got 200. Reading...")
res = await client.get(artifact, ssl=get_ssl_context()) data = await res.read()
if res.status == 200: logger.debug(f"Read {len(data)} bytes")
logger.debug("Got 200. Reading...") res_zip = BytesIO(data)
data = await res.read() if isInstalled:
logger.debug(f"Read {len(data)} bytes") try:
res_zip = BytesIO(data) logger.debug("Uninstalling existing plugin...")
await self.uninstall_plugin(name)
except Exception:
logger.error(f"Plugin {name} could not be uninstalled.")
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("Failed Downloading Remote Binaries")
else: else:
logger.fatal(f"Could not fetch from URL. {await res.text()}") self.log.fatal(f"SHA-256 Mismatch!!!! {name} (Version: {version})")
if self.loader.watcher:
# Check to make sure we got the file self.loader.watcher.disabled = False
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"Failed Downloading Remote Binaries") logger.fatal(f"Could not fetch from URL. {await res.text()}")
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())
self.install_requests[request_id] = PluginInstallContext(artifact, name, version, hash) self.install_requests[request_id] = PluginInstallContext(
artifact, name, version, hash
)
tab = await get_gamepadui_tab() tab = await get_gamepadui_tab()
await tab.open_websocket() await tab.open_websocket()
await tab.evaluate_js(f"DeckyPluginLoader.addPluginInstallPrompt('{name}', '{version}', '{request_id}', '{hash}')") await tab.evaluate_js(
f"DeckyPluginLoader.addPluginInstallPrompt('{name}', '{version}',"
f" '{request_id}', '{hash}')"
)
async def confirm_plugin_install(self, request_id): async def confirm_plugin_install(self, request_id):
request = self.install_requests.pop(request_id) request = self.install_requests.pop(request_id)
await self._install(request.artifact, request.name, request.version, request.hash) await self._install(
request.artifact, request.name, request.version, request.hash
)
def cancel_plugin_install(self, request_id): def cancel_plugin_install(self, request_id):
self.install_requests.pop(request_id) self.install_requests.pop(request_id)
-6
View File
@@ -1,6 +0,0 @@
from enum import Enum
class UserType(Enum):
HOST_USER = 1
EFFECTIVE_USER = 2
ROOT = 3
+133 -96
View File
@@ -1,18 +1,17 @@
import grp
import pwd
import re import re
import ssl import ssl
import subprocess
import uuid import uuid
import os import os
import sys import sys
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"
@@ -22,55 +21,135 @@ ssl_ctx = ssl.create_default_context(cafile=certifi.where())
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
def get_csrf_token(): def get_csrf_token():
return csrf_token return csrf_token
@middleware @middleware
async def csrf_middleware(request, handler): async def csrf_middleware(request, handler):
if str(request.method) == "OPTIONS" or request.headers.get('Authentication') == csrf_token or str(request.rel_url) == "/auth/token" or str(request.rel_url).startswith("/plugins/load_main/") or str(request.rel_url).startswith("/static/") or str(request.rel_url).startswith("/legacy/") or str(request.rel_url).startswith("/steam_resource/") or str(request.rel_url).startswith("/frontend/") or assets_regex.match(str(request.rel_url)) or frontend_regex.match(str(request.rel_url)): if (
str(request.method) == "OPTIONS"
or request.headers.get("Authentication") == csrf_token
or str(request.rel_url) == "/auth/token"
or str(request.rel_url).startswith("/plugins/load_main/")
or str(request.rel_url).startswith("/static/")
or str(request.rel_url).startswith("/legacy/")
or str(request.rel_url).startswith("/steam_resource/")
or str(request.rel_url).startswith("/frontend/")
or assets_regex.match(str(request.rel_url))
or frontend_regex.match(str(request.rel_url))
):
return await handler(request) return await handler(request)
return Response(text='Forbidden', status='403') return Response(text="Forbidden", status="403")
# Deprecated
def set_user():
pass
# Get the user id hosting the plugin loader
def get_user_id() -> int:
proc_path = os.path.realpath(sys.argv[0])
pws = sorted(pwd.getpwall(), reverse=True, key=lambda pw: len(pw.pw_dir))
for pw in pws:
if proc_path.startswith(os.path.realpath(pw.pw_dir)):
return pw.pw_uid
raise PermissionError(
"The plugin loader does not seem to be hosted by any known user."
)
# Get the user hosting the plugin loader
def get_user() -> str:
return pwd.getpwuid(get_user_id()).pw_name
# 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
# Deprecated
def set_user_group() -> str:
return get_user_group()
# 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(file_path) -> str:
if file_path:
return grp.getgrgid(os.stat(file_path).st_gid).gr_name
else:
return grp.getgrgid(get_user_group_id()).gr_name
# Get the default home path unless a user is specified
def get_home_path(username=None) -> str:
if username is None:
username = get_user()
return pwd.getpwnam(username).pw_dir
# Get the default homebrew path unless a home_path is specified
def get_homebrew_path(home_path=None) -> str:
if home_path is None:
home_path = get_home_path()
return os.path.join(home_path, "homebrew")
# Get the default homebrew path unless a home_path is specified. home_path argument is deprecated
def get_homebrew_path(home_path = None) -> str:
return localplatform.get_unprivileged_path()
# Recursively create path and chown as user # Recursively create path and chown as user
def mkdir_as_user(path): def mkdir_as_user(path):
path = os.path.realpath(path) path = os.path.realpath(path)
os.makedirs(path, exist_ok=True) os.makedirs(path, exist_ok=True)
localplatform.chown(path) chown_path = get_home_path()
parts = os.path.relpath(path, chown_path).split(os.sep)
uid = get_user_id()
gid = get_user_group_id()
for p in parts:
chown_path = os.path.join(chown_path, p)
os.chown(chown_path, uid, gid)
# Fetches the version of loader # Fetches the version of loader
def get_loader_version() -> str: def get_loader_version() -> str:
try: with open(
with open(os.path.join(os.getcwd(), ".loader.version"), "r", encoding="utf-8") as version_file: os.path.join(os.path.dirname(sys.argv[0]), ".loader.version"),
return version_file.readline().strip() "r",
except Exception as e: encoding="utf-8",
logger.warn(f"Failed to execute get_loader_version(): {str(e)}") ) as version_file:
return "unknown" return version_file.readline().replace("\n", "")
# 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:
@@ -84,79 +163,37 @@ async def download_remote_binary_to_path(url, binHash, path) -> bool:
remoteHash = sha256(data.getbuffer()).hexdigest() remoteHash = sha256(data.getbuffer()).hexdigest()
if binHash == remoteHash: if binHash == remoteHash:
data.seek(0) data.seek(0)
with open(path, 'wb') as f: with open(path, "wb") as f:
f.write(data.getbuffer()) f.write(data.getbuffer())
rv = True rv = True
else: else:
raise Exception(f"Fatal Error: Hash Mismatch for remote binary {path}@{url}") raise Exception(
f"Fatal Error: Hash Mismatch for remote binary {path}@{url}"
)
else: else:
rv = False rv = False
except: except Exception:
rv = False rv = False
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:
return await localplatform.service_active(unit_name) res = subprocess.run(
["systemctl", "is-active", unit_name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return res.returncode == 0
async def stop_systemd_unit(unit_name: str) -> bool:
return await localplatform.service_stop(unit_name)
async def start_systemd_unit(unit_name: str) -> bool: async def stop_systemd_unit(unit_name: str) -> subprocess.CompletedProcess:
return await localplatform.service_start(unit_name) cmd = ["systemctl", "stop", unit_name]
return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
async def start_systemd_unit(unit_name: str) -> subprocess.CompletedProcess:
cmd = ["systemctl", "start", unit_name]
return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
+176 -123
View File
@@ -2,10 +2,9 @@
from asyncio import sleep from asyncio import sleep
from logging import getLogger from logging import getLogger
from traceback import format_exc
from typing import List from typing import List
from aiohttp import ClientSession, WSMsgType from aiohttp import ClientSession
from aiohttp.client_exceptions import ClientConnectorError, ClientOSError from aiohttp.client_exceptions import ClientConnectorError, ClientOSError
from asyncio.exceptions import TimeoutError from asyncio.exceptions import TimeoutError
import uuid import uuid
@@ -39,9 +38,12 @@ class Tab:
async for message in self.websocket: async for message in self.websocket:
data = message.json() data = message.json()
yield data yield data
logger.warn(f"The Tab {self.title} socket has been disconnected while listening for messages.") logger.warn(
f"The Tab {self.title} socket has been disconnected while listening for"
" messages."
)
await self.close_websocket() await self.close_websocket()
async def _send_devtools_cmd(self, dc, receive=True): async def _send_devtools_cmd(self, dc, receive=True):
if self.websocket: if self.websocket:
self.cmd_id += 1 self.cmd_id += 1
@@ -54,19 +56,24 @@ class Tab:
return None return None
raise RuntimeError("Websocket not opened") raise RuntimeError("Websocket not opened")
async def evaluate_js(self, js, run_async=False, manage_socket=True, get_result=True): async def evaluate_js(
self, js, run_async=False, manage_socket=True, get_result=True
):
try: try:
if manage_socket: if manage_socket:
await self.open_websocket() await self.open_websocket()
res = await self._send_devtools_cmd({ res = await self._send_devtools_cmd(
"method": "Runtime.evaluate", {
"params": { "method": "Runtime.evaluate",
"expression": js, "params": {
"userGesture": True, "expression": js,
"awaitPromise": run_async "userGesture": True,
} "awaitPromise": run_async,
}, get_result) },
},
get_result,
)
finally: finally:
if manage_socket: if manage_socket:
@@ -74,9 +81,17 @@ class Tab:
return res return res
async def has_global_var(self, var_name, manage_socket=True): async def has_global_var(self, var_name, manage_socket=True):
res = await self.evaluate_js(f"window['{var_name}'] !== null && window['{var_name}'] !== undefined", False, manage_socket) res = await self.evaluate_js(
f"window['{var_name}'] !== null && window['{var_name}'] !== undefined",
False,
manage_socket,
)
if not "result" in res or not "result" in res["result"] or not "value" in res["result"]["result"]: if (
"result" not in res
or "result" not in res["result"]
or "value" not in res["result"]["result"]
):
return False return False
return res["result"]["result"]["value"] return res["result"]["result"]["value"]
@@ -86,9 +101,12 @@ class Tab:
if manage_socket: if manage_socket:
await self.open_websocket() await self.open_websocket()
res = await self._send_devtools_cmd({ res = await self._send_devtools_cmd(
"method": "Page.close", {
}, False) "method": "Page.close",
},
False,
)
finally: finally:
if manage_socket: if manage_socket:
@@ -99,32 +117,42 @@ class Tab:
""" """
Enables page domain notifications. Enables page domain notifications.
""" """
await self._send_devtools_cmd({ await self._send_devtools_cmd(
"method": "Page.enable", {
}, False) "method": "Page.enable",
},
False,
)
async def disable(self): async def disable(self):
""" """
Disables page domain notifications. Disables page domain notifications.
""" """
await self._send_devtools_cmd({ await self._send_devtools_cmd(
"method": "Page.disable", {
}, False) "method": "Page.disable",
},
False,
)
async def refresh(self, manage_socket=True): async def refresh(self, manage_socket=False):
try: try:
if manage_socket: if manage_socket:
await self.open_websocket() await self.open_websocket()
await self._send_devtools_cmd({ await self._send_devtools_cmd(
"method": "Page.reload", {
}, False) "method": "Page.reload",
},
False,
)
finally: finally:
if manage_socket: if manage_socket:
await self.close_websocket() await self.close_websocket()
return return
async def reload_and_evaluate(self, js, manage_socket=True): async def reload_and_evaluate(self, js, manage_socket=True):
""" """
Reloads the current tab, with JS to run on load via debugger Reloads the current tab, with JS to run on load via debugger
@@ -133,64 +161,70 @@ class Tab:
if manage_socket: if manage_socket:
await self.open_websocket() await self.open_websocket()
await self._send_devtools_cmd({ await self._send_devtools_cmd({"method": "Debugger.enable"}, True)
"method": "Debugger.enable"
}, True)
await self._send_devtools_cmd({ await self._send_devtools_cmd(
"method": "Runtime.evaluate", {
"params": { "method": "Runtime.evaluate",
"expression": "location.reload();", "params": {
"userGesture": True, "expression": "location.reload();",
"awaitPromise": False "userGesture": True,
} "awaitPromise": False,
}, False) },
},
False,
)
breakpoint_res = await self._send_devtools_cmd({ breakpoint_res = await self._send_devtools_cmd(
"method": "Debugger.setInstrumentationBreakpoint", {
"params": { "method": "Debugger.setInstrumentationBreakpoint",
"instrumentation": "beforeScriptExecution" "params": {"instrumentation": "beforeScriptExecution"},
} },
}, True) True,
)
logger.info(breakpoint_res) logger.info(breakpoint_res)
# Page finishes loading when breakpoint hits # Page finishes loading when breakpoint hits
for x in range(20): for x in range(20):
# this works around 1/5 of the time, so just send it 8 times. # this works around 1/5 of the time, so just send it 8 times.
# the js accounts for being injected multiple times allowing only one instance to run at a time anyway # the js accounts for being injected multiple times allowing only one instance to run at a time anyway
await self._send_devtools_cmd({ await self._send_devtools_cmd(
"method": "Runtime.evaluate", {
"params": { "method": "Runtime.evaluate",
"expression": js, "params": {
"userGesture": True, "expression": js,
"awaitPromise": False "userGesture": True,
} "awaitPromise": False,
}, False) },
},
False,
)
await self._send_devtools_cmd({ await self._send_devtools_cmd(
"method": "Debugger.removeBreakpoint", {
"params": { "method": "Debugger.removeBreakpoint",
"breakpointId": breakpoint_res["result"]["breakpointId"] "params": {
} "breakpointId": breakpoint_res["result"]["breakpointId"]
}, False) },
},
False,
)
for x in range(4): for x in range(4):
await self._send_devtools_cmd({ await self._send_devtools_cmd({"method": "Debugger.resume"}, False)
"method": "Debugger.resume"
}, False)
await self._send_devtools_cmd({ await self._send_devtools_cmd({"method": "Debugger.disable"}, True)
"method": "Debugger.disable"
}, True)
finally: finally:
if manage_socket: if manage_socket:
await self.close_websocket() await self.close_websocket()
return return
async def add_script_to_evaluate_on_new_document(self, js, add_dom_wrapper=True, manage_socket=True, get_result=True): async def add_script_to_evaluate_on_new_document(
self, js, add_dom_wrapper=True, manage_socket=True, get_result=True
):
""" """
How the underlying call functions is not particularly clear from the devtools docs, so stealing puppeteer's description: How the underlying call functions is not particularly clear from the devtools docs, so stealing puppeteer's description:
@@ -225,35 +259,44 @@ class Tab:
""" """
try: try:
wrappedjs = """ wrappedjs = (
function scriptFunc() { """
function scriptFunc() {{
{js} {js}
} }}
if (document.readyState === 'loading') { if (document.readyState === 'loading') {{
addEventListener('DOMContentLoaded', () => { addEventListener('DOMContentLoaded', () => {{
scriptFunc(); scriptFunc();
}); }});
} else { }} else {{
scriptFunc(); scriptFunc();
} }}
""".format(js=js) if add_dom_wrapper else js """.format(
js=js
)
if add_dom_wrapper
else js
)
if manage_socket: if manage_socket:
await self.open_websocket() await self.open_websocket()
res = await self._send_devtools_cmd({ res = await self._send_devtools_cmd(
"method": "Page.addScriptToEvaluateOnNewDocument", {
"params": { "method": "Page.addScriptToEvaluateOnNewDocument",
"source": wrappedjs "params": {"source": wrappedjs},
} },
}, get_result) get_result,
)
finally: finally:
if manage_socket: if manage_socket:
await self.close_websocket() await self.close_websocket()
return res return res
async def remove_script_to_evaluate_on_new_document(self, script_id, manage_socket=True): async def remove_script_to_evaluate_on_new_document(
self, script_id, manage_socket=True
):
""" """
Removes a script from a page that was added with `add_script_to_evaluate_on_new_document` Removes a script from a page that was added with `add_script_to_evaluate_on_new_document`
@@ -267,21 +310,28 @@ class Tab:
if manage_socket: if manage_socket:
await self.open_websocket() await self.open_websocket()
res = await self._send_devtools_cmd({ await self._send_devtools_cmd(
"method": "Page.removeScriptToEvaluateOnNewDocument", {
"params": { "method": "Page.removeScriptToEvaluateOnNewDocument",
"identifier": script_id "params": {"identifier": script_id},
} },
}, False) False,
)
finally: finally:
if manage_socket: if manage_socket:
await self.close_websocket() await self.close_websocket()
async def has_element(self, element_name, manage_socket=True): async def has_element(self, element_name, manage_socket=True):
res = await self.evaluate_js(f"document.getElementById('{element_name}') != null", False, manage_socket) res = await self.evaluate_js(
f"document.getElementById('{element_name}') != null", False, manage_socket
)
if not "result" in res or not "result" in res["result"] or not "value" in res["result"]["result"]: if (
"result" not in res
or "result" not in res["result"]
or "value" not in res["result"]["result"]
):
return False return False
return res["result"]["result"]["value"] return res["result"]["result"]["value"]
@@ -298,23 +348,17 @@ class Tab:
document.head.append(style); document.head.append(style);
style.textContent = `{style}`; style.textContent = `{style}`;
}})() }})()
""", False, manage_socket) """,
False,
manage_socket,
)
if "exceptionDetails" in result["result"]: if "exceptionDetails" in result["result"]:
return { return {"success": False, "result": result["result"]}
"success": False,
"result": result["result"]
}
return { return {"success": True, "result": css_id}
"success": True,
"result": css_id
}
except Exception as e: except Exception as e:
return { return {"success": False, "result": e}
"success": False,
"result": e
}
async def remove_css(self, css_id, manage_socket=True): async def remove_css(self, css_id, manage_socket=True):
try: try:
@@ -326,25 +370,24 @@ class Tab:
if (style.nodeName.toLowerCase() == 'style') if (style.nodeName.toLowerCase() == 'style')
style.parentNode.removeChild(style); style.parentNode.removeChild(style);
}})() }})()
""", False, manage_socket) """,
False,
manage_socket,
)
if "exceptionDetails" in result["result"]: if "exceptionDetails" in result["result"]:
return { return {"success": False, "result": result}
"success": False,
"result": result
}
return { return {"success": True}
"success": True
}
except Exception as e: except Exception as e:
return { return {"success": False, "result": e}
"success": False,
"result": e
}
async def get_steam_resource(self, url): async def get_steam_resource(self, url):
res = await self.evaluate_js(f'(async function test() {{ return await (await fetch("{url}")).text() }})()', True) res = await self.evaluate_js(
f'(async function test() {{ return await (await fetch("{url}")).text()'
" })()",
True,
)
return res["result"]["result"]["value"] return res["result"]["result"]["value"]
def __repr__(self): def __repr__(self):
@@ -387,35 +430,45 @@ async def get_tab(tab_name) -> Tab:
raise ValueError(f"Tab {tab_name} not found") raise ValueError(f"Tab {tab_name} not found")
return tab return tab
async def get_tab_lambda(test) -> Tab: async def get_tab_lambda(test) -> Tab:
tabs = await get_tabs() tabs = await get_tabs()
tab = next((i for i in tabs if test(i)), None) tab = next((i for i in tabs if test(i)), None)
if not tab: if not tab:
raise ValueError(f"Tab not found by lambda") raise ValueError("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: def tab_is_gamepadui(t: Tab) -> bool:
return "https://steamloopback.host/routes/" in t.url and t.title in SHARED_CTX_NAMES return "https://steamloopback.host/routes/" in t.url and (
t.title == "Steam Shared Context presented by Valve™"
or t.title == "Steam"
or t.title == "SP"
)
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 tab_is_gamepadui(i)), 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("GamepadUI Tab not found")
return tab return tab
async def inject_to_tab(tab_name, js, run_async=False): 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(): async def close_old_tabs():
tabs = await get_tabs() tabs = await get_tabs()
for t in 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): if not t.title or (
t.title != "Steam Shared Context presented by Valve™"
and t.title != "Steam"
and t.title != "SP"
):
logger.debug("Closing tab: " + getattr(t, "title", "Untitled")) logger.debug("Closing tab: " + getattr(t, "title", "Untitled"))
await t.close() await t.close()
await sleep(0.5) await sleep(0.5)
+103 -35
View File
@@ -6,16 +6,22 @@ from pathlib import Path
from traceback import print_exc from traceback import print_exc
from aiohttp import web from aiohttp import web
from os.path import exists from genericpath import exists
from watchdog.events import RegexMatchingEventHandler from watchdog.events import RegexMatchingEventHandler
from watchdog.observers import Observer from watchdog.utils import UnsupportedLibc
try:
from watchdog.observers.inotify import InotifyObserver as Observer
except UnsupportedLibc:
from watchdog.observers.fsevents import FSEventsObserver as Observer
from injector import get_tab, 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$"])
self.logger = getLogger("file-watcher") self.logger = getLogger("file-watcher")
self.plugin_path = plugin_path self.plugin_path = plugin_path
self.queue = queue self.queue = queue
@@ -26,7 +32,9 @@ class FileChangeHandler(RegexMatchingEventHandler):
return return
plugin_dir = Path(path.relpath(src_path, self.plugin_path)).parts[0] plugin_dir = Path(path.relpath(src_path, self.plugin_path)).parts[0]
if exists(path.join(self.plugin_path, plugin_dir, "plugin.json")): if exists(path.join(self.plugin_path, plugin_dir, "plugin.json")):
self.queue.put_nowait((path.join(self.plugin_path, plugin_dir, "main.py"), plugin_dir, True)) self.queue.put_nowait(
(path.join(self.plugin_path, plugin_dir, "main.py"), plugin_dir, True)
)
def on_created(self, event): def on_created(self, event):
src_path = event.src_path src_path = event.src_path
@@ -56,6 +64,7 @@ class FileChangeHandler(RegexMatchingEventHandler):
self.logger.debug(f"file modified: {src_path}") self.logger.debug(f"file modified: {src_path}")
self.maybe_reload(src_path) self.maybe_reload(src_path)
class Loader: class Loader:
def __init__(self, server_instance, plugin_path, loop, live_reload=False) -> None: def __init__(self, server_instance, plugin_path, loop, live_reload=False) -> None:
self.loop = loop self.loop = loop
@@ -75,18 +84,30 @@ class Loader:
self.loop.create_task(self.handle_reloads()) self.loop.create_task(self.handle_reloads())
self.loop.create_task(self.enable_reload_wait()) self.loop.create_task(self.enable_reload_wait())
server_instance.add_routes([ server_instance.add_routes(
web.get("/frontend/{path:.*}", self.handle_frontend_assets), [
web.get("/plugins", self.get_plugins), web.get("/frontend/{path:.*}", self.handle_frontend_assets),
web.get("/plugins/{plugin_name}/frontend_bundle", self.handle_frontend_bundle), web.get("/plugins", self.get_plugins),
web.post("/plugins/{plugin_name}/methods/{method_name}", self.handle_plugin_method_call), web.get(
web.get("/plugins/{plugin_name}/assets/{path:.*}", self.handle_plugin_frontend_assets), "/plugins/{plugin_name}/frontend_bundle",
self.handle_frontend_bundle,
# The following is legacy plugin code. ),
web.get("/plugins/load_main/{name}", self.load_plugin_main_view), web.post(
web.get("/plugins/plugin_resource/{name}/{path:.+}", self.handle_sub_route), "/plugins/{plugin_name}/methods/{method_name}",
web.get("/steam_resource/{path:.+}", self.get_steam_resource) self.handle_plugin_method_call,
]) ),
web.get(
"/plugins/{plugin_name}/assets/{path:.*}",
self.handle_plugin_frontend_assets,
),
# The following is legacy plugin code.
web.get("/plugins/load_main/{name}", self.load_plugin_main_view),
web.get(
"/plugins/plugin_resource/{name}/{path:.+}", self.handle_sub_route
),
web.get("/steam_resource/{path:.+}", self.get_steam_resource),
]
)
async def enable_reload_wait(self): async def enable_reload_wait(self):
if self.live_reload: if self.live_reload:
@@ -101,36 +122,63 @@ class Loader:
async def get_plugins(self, request): async def get_plugins(self, request):
plugins = list(self.plugins.values()) plugins = list(self.plugins.values())
return web.json_response([{"name": str(i) if not i.legacy else "$LEGACY_"+str(i), "version": i.version} for i in plugins]) return web.json_response(
[
{
"name": str(i) if not i.legacy else "$LEGACY_" + str(i),
"version": i.version,
}
for i in plugins
]
)
def handle_plugin_frontend_assets(self, request): def handle_plugin_frontend_assets(self, request):
plugin = self.plugins[request.match_info["plugin_name"]] plugin = self.plugins[request.match_info["plugin_name"]]
file = path.join(self.plugin_path, plugin.plugin_directory, "dist/assets", request.match_info["path"]) file = path.join(
self.plugin_path,
plugin.plugin_directory,
"dist/assets",
request.match_info["path"],
)
return web.FileResponse(file, headers={"Cache-Control": "no-cache"}) return web.FileResponse(file, headers={"Cache-Control": "no-cache"})
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", encoding="utf-8") as bundle: with open(
return web.Response(text=bundle.read(), content_type="application/javascript") 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"
)
def import_plugin(self, file, plugin_directory, refresh=False, batch=False): def import_plugin(self, file, plugin_directory, refresh=False, batch=False):
try: try:
plugin = PluginWrapper(file, plugin_directory, self.plugin_path) plugin = PluginWrapper(file, plugin_directory, self.plugin_path)
if plugin.name in self.plugins: if plugin.name in self.plugins:
if not "debug" in plugin.flags and refresh: if "debug" not in plugin.flags and refresh:
self.logger.info(f"Plugin {plugin.name} is already loaded and has requested to not be re-loaded") self.logger.info(
return f"Plugin {plugin.name} is already loaded and has requested to"
else: " not be re-loaded"
self.plugins[plugin.name].stop() )
self.plugins.pop(plugin.name, None) return
else:
self.plugins[plugin.name].stop()
self.plugins.pop(plugin.name, None)
if plugin.passive: if plugin.passive:
self.logger.info(f"Plugin {plugin.name} is passive") self.logger.info(f"Plugin {plugin.name} is passive")
self.plugins[plugin.name] = plugin.start() self.plugins[plugin.name] = plugin.start()
self.logger.info(f"Loaded {plugin.name}") self.logger.info(f"Loaded {plugin.name}")
if not batch: if not batch:
self.loop.create_task(self.dispatch_plugin(plugin.name if not plugin.legacy else "$LEGACY_" + plugin.name, plugin.version)) self.loop.create_task(
self.dispatch_plugin(
plugin.name if not plugin.legacy else "$LEGACY_" + plugin.name,
plugin.version,
)
)
except Exception as e: except Exception as e:
self.logger.error(f"Could not load {file}. {e}") self.logger.error(f"Could not load {file}. {e}")
print_exc() print_exc()
@@ -142,10 +190,20 @@ class Loader:
def import_plugins(self): def import_plugins(self):
self.logger.info(f"import plugins from {self.plugin_path}") self.logger.info(f"import plugins from {self.plugin_path}")
directories = [i for i in listdir(self.plugin_path) if path.isdir(path.join(self.plugin_path, i)) and path.isfile(path.join(self.plugin_path, i, "plugin.json"))] directories = [
i
for i in listdir(self.plugin_path)
if path.isdir(path.join(self.plugin_path, i))
and path.isfile(path.join(self.plugin_path, i, "plugin.json"))
]
for directory in directories: for directory in directories:
self.logger.info(f"found plugin: {directory}") self.logger.info(f"found plugin: {directory}")
self.import_plugin(path.join(self.plugin_path, directory, "main.py"), directory, False, True) self.import_plugin(
path.join(self.plugin_path, directory, "main.py"),
directory,
False,
True,
)
async def handle_reloads(self): async def handle_reloads(self):
while True: while True:
@@ -162,10 +220,10 @@ class Loader:
except JSONDecodeError: except JSONDecodeError:
args = {} args = {}
try: try:
if method_name.startswith("_"): if method_name.startswith("_"):
raise RuntimeError("Tried to call private method") raise RuntimeError("Tried to call private method")
res["result"] = await plugin.execute_method(method_name, args) res["result"] = await plugin.execute_method(method_name, args)
res["success"] = True res["success"] = True
except Exception as e: except Exception as e:
res["result"] = str(e) res["result"] = str(e)
res["success"] = False res["success"] = False
@@ -178,9 +236,14 @@ class Loader:
can introduce it more smoothly and give people the chance to sample the new features even can introduce it more smoothly and give people the chance to sample the new features even
without plugin support. They will be removed once legacy plugins are no longer relevant. without plugin support. They will be removed once legacy plugins are no longer relevant.
""" """
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", encoding="utf-8") 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>
@@ -204,6 +267,11 @@ class Loader:
async def get_steam_resource(self, request): async def get_steam_resource(self, request):
tab = await get_tab("SP") tab = await get_tab("SP")
try: try:
return web.Response(text=await tab.get_steam_resource(f"https://steamloopback.host/{request.match_info['path']}"), content_type="text/html") return web.Response(
text=await tab.get_steam_resource(
f"https://steamloopback.host/{request.match_info['path']}"
),
content_type="text/html",
)
except Exception as e: except Exception as e:
return web.Response(text=str(e), status=400) return web.Response(text=str(e), status=400)
-40
View File
@@ -1,40 +0,0 @@
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")
]
-194
View File
@@ -1,194 +0,0 @@
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
-53
View File
@@ -1,53 +0,0 @@
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())
-132
View File
@@ -1,132 +0,0 @@
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
+89 -60
View File
@@ -1,72 +1,95 @@
# Change PyInstaller files permissions # Change PyInstaller files permissions
import sys import sys
from localplatform import (chmod, chown, service_stop, service_start, from subprocess import call
ON_WINDOWS, get_log_level, get_live_reload,
get_server_port, get_server_host, get_chown_plugin_path, if hasattr(sys, "_MEIPASS"):
get_unprivileged_user, get_unprivileged_path, call(["chmod", "-R", "755", sys._MEIPASS])
get_privileged_path)
if hasattr(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 logging import basicConfig, getLogger
from logging import DEBUG, INFO, basicConfig, getLogger
from os import getenv, 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
from aiohttp import client_exceptions, WSMsgType from aiohttp import client_exceptions
from aiohttp.web import Application, Response, get, run_app, static from aiohttp.web import Application, Response, get, run_app, static
from aiohttp_jinja2 import setup as jinja_setup 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 (
mkdir_as_user, get_system_pythonpaths) REMOTE_DEBUGGER_UNIT,
csrf_middleware,
from injector import get_gamepadui_tab, Tab, get_tabs, close_old_tabs get_csrf_token,
get_homebrew_path,
get_user,
get_user_group,
stop_systemd_unit,
start_systemd_unit,
)
from injector import get_gamepadui_tab, Tab, close_old_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
USER = get_user()
GROUP = get_user_group()
HOMEBREW_PATH = get_homebrew_path()
CONFIG = {
"plugin_path": getenv("PLUGIN_PATH", path.join(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=get_log_level(), level=CONFIG["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")
def chown_plugin_dir(): def chown_plugin_dir():
if not path.exists(plugin_path): # For safety, create the folder before attempting to do anything with it code_chown = call(["chown", "-R", USER + ":" + GROUP, CONFIG["plugin_path"]])
mkdir_as_user(plugin_path) code_chmod = call(["chmod", "-R", "555", CONFIG["plugin_path"]])
if code_chown != 0 or code_chmod != 0:
logger.error(
f"chown/chmod exited with a non-zero exit code (chown: {code_chown}, chmod:"
f" {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: if CONFIG["chown_plugin_path"] is True:
chown_plugin_dir() chown_plugin_dir()
class PluginManager: class PluginManager:
def __init__(self, loop) -> None: def __init__(self, loop) -> None:
self.loop = loop self.loop = loop
self.web_app = Application() self.web_app = Application()
self.web_app.middlewares.append(csrf_middleware) self.web_app.middlewares.append(csrf_middleware)
self.cors = aiohttp_cors.setup(self.web_app, defaults={ self.cors = aiohttp_cors.setup(
"https://steamloopback.host": aiohttp_cors.ResourceOptions( self.web_app,
expose_headers="*", defaults={
allow_headers="*", "https://steamloopback.host": aiohttp_cors.ResourceOptions(
allow_credentials=True expose_headers="*", allow_headers="*", allow_credentials=True
) )
}) },
self.plugin_loader = Loader(self.web_app, plugin_path, self.loop, get_live_reload()) )
self.settings = SettingsManager("loader", path.join(get_privileged_path(), "settings")) self.plugin_loader = Loader(
self.plugin_browser = PluginBrowser(plugin_path, self.plugin_loader.plugins, self.plugin_loader, self.settings) self.web_app, CONFIG["plugin_path"], self.loop, CONFIG["live_reload"]
)
self.plugin_browser = PluginBrowser(
CONFIG["plugin_path"], self.plugin_loader.plugins, self.plugin_loader
)
self.settings = SettingsManager("loader", path.join(HOMEBREW_PATH, "settings"))
self.utilities = Utilities(self) self.utilities = Utilities(self)
self.updater = Updater(self) self.updater = Updater(self)
@@ -74,9 +97,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(service_start(REMOTE_DEBUGGER_UNIT)) self.loop.create_task(start_systemd_unit(REMOTE_DEBUGGER_UNIT))
else: else:
self.loop.create_task(service_stop(REMOTE_DEBUGGER_UNIT)) self.loop.create_task(stop_systemd_unit(REMOTE_DEBUGGER_UNIT))
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())
@@ -87,8 +110,12 @@ class PluginManager:
for route in list(self.web_app.router.routes()): for route in list(self.web_app.router.routes()):
self.cors.add(route) self.cors.add(route)
self.web_app.add_routes([static("/static", path.join(path.dirname(__file__), 'static'))]) self.web_app.add_routes(
self.web_app.add_routes([static("/legacy", path.join(path.dirname(__file__), 'legacy'))]) [static("/static", path.join(path.dirname(__file__), "static"))]
)
self.web_app.add_routes(
[static("/legacy", path.join(path.dirname(__file__), "legacy"))]
)
def exception_handler(self, loop, context): def exception_handler(self, loop, context):
if context["message"] == "Unclosed connection": if context["message"] == "Unclosed connection":
@@ -103,9 +130,6 @@ 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:
@@ -115,7 +139,10 @@ class PluginManager:
while not tab: while not tab:
try: try:
tab = await get_gamepadui_tab() tab = await get_gamepadui_tab()
except (client_exceptions.ClientConnectorError, 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
@@ -146,7 +173,7 @@ class PluginManager:
# This is because of https://github.com/aio-libs/aiohttp/blob/3ee7091b40a1bc58a8d7846e7878a77640e96996/aiohttp/client_ws.py#L321 # This is because of https://github.com/aio-libs/aiohttp/blob/3ee7091b40a1bc58a8d7846e7878a77640e96996/aiohttp/client_ws.py#L321
logger.info("CEF has disconnected...") logger.info("CEF has disconnected...")
# At this point the loop starts again and we connect to the freshly started Steam client once it is ready. # At this point the loop starts again and we connect to the freshly started Steam client once it is ready.
except Exception as e: except Exception:
logger.error("Exception while reading page events " + format_exc()) logger.error("Exception while reading page events " + format_exc())
await tab.close_websocket() await tab.close_websocket()
pass pass
@@ -162,29 +189,31 @@ class PluginManager:
if first: if first:
if await tab.has_global_var("deckyHasLoaded", False): if await tab.has_global_var("deckyHasLoaded", False):
await close_old_tabs() await close_old_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) await tab.evaluate_js(
except: "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,
)
except Exception:
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=get_server_host(), port=get_server_port(), loop=self.loop, access_log=None) return run_app(
self.web_app,
host=CONFIG["server_host"],
port=CONFIG["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()
+152 -62
View File
@@ -1,33 +1,57 @@
import multiprocessing import multiprocessing
from asyncio import (Lock, get_event_loop, new_event_loop, from asyncio import (
set_event_loop, sleep) Lock,
from concurrent.futures import ProcessPoolExecutor get_event_loop,
new_event_loop,
open_unix_connection,
set_event_loop,
sleep,
start_unix_server,
IncompleteReadError,
LimitOverrunError,
)
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, environ from os import path, setgid, setuid, environ
from signal import SIGINT, signal from signal import SIGINT, signal
from sys import exit, path as syspath from sys import exit
from time import time from time import time
from localsocket import LocalSocket
from localplatform import setgid, setuid, get_username, get_home_path
from customtypes import UserType
import helpers import helpers
multiprocessing.set_start_method("fork")
BUFFER_LIMIT = 2**20 # 1 MiB
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_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", encoding="utf-8")) 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", encoding="utf-8")) 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
@@ -53,76 +77,120 @@ class PluginWrapper:
set_event_loop(new_event_loop()) set_event_loop(new_event_loop())
if self.passive: if self.passive:
return return
setgid(UserType.ROOT if "root" in self.flags else UserType.HOST_USER) setgid(0 if "root" in self.flags else helpers.get_user_group_id())
setuid(UserType.ROOT if "root" in self.flags else UserType.HOST_USER) setuid(0 if "root" in self.flags else helpers.get_user_id())
# export a bunch of environment variables to help plugin developers # 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["HOME"] = helpers.get_home_path(
environ["USER"] = "root" if "root" in self.flags else get_username() "root" if "root" in self.flags else helpers.get_user()
)
environ["USER"] = "root" if "root" in self.flags else helpers.get_user()
environ["DECKY_VERSION"] = helpers.get_loader_version() environ["DECKY_VERSION"] = helpers.get_loader_version()
environ["DECKY_USER"] = get_username() environ["DECKY_USER"] = helpers.get_user()
environ["DECKY_USER_HOME"] = helpers.get_home_path()
environ["DECKY_HOME"] = helpers.get_homebrew_path() environ["DECKY_HOME"] = helpers.get_homebrew_path()
environ["DECKY_PLUGIN_SETTINGS_DIR"] = path.join(environ["DECKY_HOME"], "settings", self.plugin_directory) environ["DECKY_PLUGIN_SETTINGS_DIR"] = path.join(
environ["DECKY_HOME"], "settings", self.plugin_directory
)
helpers.mkdir_as_user(environ["DECKY_PLUGIN_SETTINGS_DIR"]) helpers.mkdir_as_user(environ["DECKY_PLUGIN_SETTINGS_DIR"])
environ["DECKY_PLUGIN_RUNTIME_DIR"] = path.join(environ["DECKY_HOME"], "data", self.plugin_directory) environ["DECKY_PLUGIN_RUNTIME_DIR"] = path.join(
environ["DECKY_HOME"], "data", self.plugin_directory
)
helpers.mkdir_as_user(environ["DECKY_PLUGIN_RUNTIME_DIR"]) helpers.mkdir_as_user(environ["DECKY_PLUGIN_RUNTIME_DIR"])
environ["DECKY_PLUGIN_LOG_DIR"] = path.join(environ["DECKY_HOME"], "logs", self.plugin_directory) environ["DECKY_PLUGIN_LOG_DIR"] = path.join(
environ["DECKY_HOME"], "logs", self.plugin_directory
)
helpers.mkdir_as_user(environ["DECKY_PLUGIN_LOG_DIR"]) helpers.mkdir_as_user(environ["DECKY_PLUGIN_LOG_DIR"])
environ["DECKY_PLUGIN_DIR"] = path.join(self.plugin_path, self.plugin_directory) environ["DECKY_PLUGIN_DIR"] = path.join(
self.plugin_path, self.plugin_directory
)
environ["DECKY_PLUGIN_NAME"] = self.name environ["DECKY_PLUGIN_NAME"] = self.name
environ["DECKY_PLUGIN_VERSION"] = self.version environ["DECKY_PLUGIN_VERSION"] = self.version
environ["DECKY_PLUGIN_AUTHOR"] = self.author 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.socket.setup_server()) get_event_loop().create_task(self._setup_socket())
get_event_loop().run_forever() get_event_loop().run_forever()
except: except Exception:
self.log.error("Failed to start " + self.name + "!\n" + format_exc()) self.log.error("Failed to start " + self.name + "!\n" + format_exc())
exit(0) exit(0)
async def _unload(self): async def _unload(self):
try: try:
self.log.info("Attempting to unload with plugin " + self.name + "'s \"_unload\" function.\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") self.log.info("Unloaded " + self.name + "\n")
else: else:
self.log.info("Could not find \"_unload\" in " + self.name + "'s main.py" + "\n") self.log.info(
except: 'Could not find "_unload" in ' + self.name + "'s main.py" + "\n"
)
except Exception:
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 _on_new_message(self, message : str) -> str|None: async def _setup_socket(self):
data = loads(message) self.socket = await start_unix_server(
self._listen_for_method_call, path=self.socket_addr, limit=BUFFER_LIMIT
)
if "stop" in data: async def _listen_for_method_call(self, reader, writer):
self.log.info("Calling Loader unload function.") while True:
await self._unload() line = bytearray()
get_event_loop().stop() while True:
while get_event_loop().is_running(): try:
await sleep(0) line.extend(await reader.readuntil())
get_event_loop().close() except LimitOverrunError:
raise Exception("Closing message listener") line.extend(await reader.read(reader._limit))
continue
except IncompleteReadError as err:
line.extend(err.partial)
break
else:
break
data = loads(line.decode("utf-8"))
if "stop" in data:
self.log.info("Calling Loader unload function.")
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, ensure_ascii=False) + "\n").encode("utf-8"))
await writer.drain()
d = {"res": None, "success": True} async def _open_socket_if_not_exists(self):
try: if not self.reader:
d["res"] = await getattr(self.Plugin, data["method"])(self.Plugin, **data["args"]) retries = 0
except Exception as e: while retries < 10:
d["res"] = str(e) try:
d["success"] = False self.reader, self.writer = await open_unix_connection(
finally: self.socket_addr, limit=BUFFER_LIMIT
return dumps(d, ensure_ascii=False) )
return True
except Exception:
await sleep(2)
retries += 1
return False
else:
return True
def start(self): def start(self):
if self.passive: if self.passive:
@@ -135,22 +203,44 @@ class PluginWrapper:
return return
async def _(self): async def _(self):
await self.socket.write_single_line(dumps({ "stop": True }, ensure_ascii=False)) if await self._open_socket_if_not_exists():
await self.socket.close_socket_connection() self.writer.write(
(dumps({"stop": True}, ensure_ascii=False) + "\n").encode("utf-8")
)
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:
reader, writer = await self.socket.get_socket_connection() if await self._open_socket_if_not_exists():
self.writer.write(
await self.socket.write_single_line(dumps({ "method": method_name, "args": kwargs }, ensure_ascii=False)) (
dumps(
line = await self.socket.read_single_line() {"method": method_name, "args": kwargs}, ensure_ascii=False
if line != None: )
res = loads(line) + "\n"
).encode("utf-8")
)
await self.writer.drain()
line = bytearray()
while True:
try:
line.extend(await self.reader.readuntil())
except LimitOverrunError:
line.extend(await self.reader.read(self.reader._limit))
continue
except IncompleteReadError as err:
line.extend(err.partial)
break
else:
break
res = loads(line.decode("utf-8"))
if not res["success"]: if not res["success"]:
raise Exception(res["res"]) raise Exception(res["res"])
return res["res"] return res["res"]
+15
View File
@@ -0,0 +1,15 @@
[flake8]
max-line-length = 88
[tool.ruff]
ignore = [
# Ignore line length check and let Black handle it
"E501",
# Ignore SyntaxError due to ruff not supporting pattern matching
# https://github.com/charliermarsh/ruff/issues/282
"E999",
]
# Assume Python 3.10.
target-version = "py310"
+20 -16
View File
@@ -1,41 +1,45 @@
from json import dump, load from json import dump, load
from os import mkdir, path, listdir, rename from os import mkdir, path, listdir, rename
from localplatform import chown, folder_owner, get_chown_plugin_path from shutil import chown
from customtypes import UserType
from helpers import get_homebrew_path from helpers import (
get_homebrew_path,
get_user,
get_user_group,
get_user_owner,
)
class SettingsManager: class SettingsManager:
def __init__(self, name, settings_directory = None) -> None: def __init__(self, name, settings_directory=None) -> None:
USER = get_user()
GROUP = get_user_group()
wrong_dir = get_homebrew_path() wrong_dir = get_homebrew_path()
if settings_directory == None: if settings_directory is None:
settings_directory = path.join(wrong_dir, "settings") 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 # 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)
chown(settings_directory, USER, GROUP)
#Copy all old settings file in the root directory to the correct folder # Copy all old settings file in the root directory to the correct folder
for file in listdir(wrong_dir): for file in listdir(wrong_dir):
if file.endswith(".json"): if file.endswith(".json"):
rename(path.join(wrong_dir,file), rename(path.join(wrong_dir, file), path.join(settings_directory, file))
path.join(settings_directory, file))
self.path = path.join(settings_directory, name + ".json") 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:
#If the owner of the settings directory is not the user, then set it as the user: if get_user_owner(settings_directory) != USER:
expected_user = UserType.HOST_USER if get_chown_plugin_path() else UserType.ROOT chown(settings_directory, USER, GROUP)
if folder_owner(settings_directory) != expected_user:
chown(settings_directory, expected_user, False)
self.settings = {} self.settings = {}
try: try:
open(self.path, "x", encoding="utf-8") open(self.path, "x", encoding="utf-8")
except FileExistsError as e: except FileExistsError:
self.read() self.read()
pass pass
@@ -51,7 +55,7 @@ class SettingsManager:
with open(self.path, "w+", encoding="utf-8") as file: with open(self.path, "w+", encoding="utf-8") as file:
dump(self.settings, file, indent=4, ensure_ascii=False) dump(self.settings, file, indent=4, ensure_ascii=False)
def getSetting(self, key, default=None): def getSetting(self, key, default):
return self.settings.get(key, default) return self.settings.get(key, default)
def setSetting(self, key, value): def setSetting(self, key, value):
+120 -78
View File
@@ -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 localplatform import chmod, service_restart, ON_LINUX from subprocess import call
from aiohttp import ClientSession, web from aiohttp import ClientSession, web
@@ -16,6 +16,7 @@ from settings import SettingsManager
logger = getLogger("Updater") logger = getLogger("Updater")
class Updater: class Updater:
def __init__(self, context) -> None: def __init__(self, context) -> None:
self.context = context self.context = context
@@ -26,22 +27,27 @@ class Updater:
"get_version": self.get_version, "get_version": self.get_version,
"do_update": self.do_update, "do_update": self.do_update,
"do_restart": self.do_restart, "do_restart": self.do_restart,
"check_for_updates": self.check_for_updates "check_for_updates": self.check_for_updates,
} }
self.remoteVer = None self.remoteVer = None
self.allRemoteVers = None self.allRemoteVers = None
self.localVer = helpers.get_loader_version() try:
self.localVer = helpers.get_loader_version()
except:
self.localVer = False
try: try:
self.currentBranch = self.get_branch(self.context.settings) self.currentBranch = self.get_branch(self.context.settings)
except: except:
self.currentBranch = 0 self.currentBranch = 0
logger.error("Current branch could not be determined, defaulting to \"Stable\"") logger.error(
'Current branch could not be determined, defaulting to "Stable"'
)
if context: if context:
context.web_app.add_routes([ context.web_app.add_routes(
web.post("/updater/{method_name}", self._handle_server_method_call) [web.post("/updater/{method_name}", self._handle_server_method_call)]
]) )
context.loop.create_task(self.version_reloader()) context.loop.create_task(self.version_reloader())
async def _handle_server_method_call(self, request): async def _handle_server_method_call(self, request):
@@ -65,7 +71,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 "-pre" in self.localVer: if self.localVer.startswith("v") and self.localVer.find("-pre"):
logger.info("Current version determined to be pre-release") logger.info("Current version determined to be pre-release")
return 1 return 1
else: else:
@@ -86,47 +92,71 @@ class Updater:
case 1 | 2: case 1 | 2:
url = "https://raw.githubusercontent.com/SteamDeckHomebrew/decky-loader/main/dist/plugin_loader-prerelease.service" url = "https://raw.githubusercontent.com/SteamDeckHomebrew/decky-loader/main/dist/plugin_loader-prerelease.service"
case _: case _:
logger.error("You have an invalid branch set... Defaulting to prerelease service, please send the logs to the devs!") logger.error(
"You have an invalid branch set... Defaulting to prerelease"
" service, please send the logs to the devs!"
)
url = "https://raw.githubusercontent.com/SteamDeckHomebrew/decky-loader/main/dist/plugin_loader-prerelease.service" url = "https://raw.githubusercontent.com/SteamDeckHomebrew/decky-loader/main/dist/plugin_loader-prerelease.service"
return str(url) return str(url)
async def get_version(self): async def get_version(self):
return { if self.localVer:
"current": self.localVer, return {
"remote": self.remoteVer, "current": self.localVer,
"all": self.allRemoteVers, "remote": self.remoteVer,
"updatable": self.localVer != "unknown" "all": self.allRemoteVers,
} "updatable": self.localVer != None,
}
else:
return {
"current": "unknown",
"remote": self.remoteVer,
"all": self.allRemoteVers,
"updatable": False,
}
async def check_for_updates(self): async def check_for_updates(self):
logger.debug("checking for updates") logger.debug("checking for updates")
selectedBranch = self.get_branch(self.context.settings) selectedBranch = self.get_branch(self.context.settings)
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 not ver["tag_name"].find("-pre") > 0 and ver["tag_name"], remoteVersions), None) self.remoteVer = next(
filter(
lambda ver: ver["tag_name"].startswith("v")
and not ver["prerelease"]
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["tag_name"].startswith("v"), remoteVersions), None) self.remoteVer = next(
filter(
lambda ver: ver["prerelease"]
and ver["tag_name"].startswith("v")
and ver["tag_name"].find("-pre"),
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")
logger.info("Updated remote version information") logger.info("Updated remote version information")
tab = await get_gamepadui_tab() tab = await get_gamepadui_tab()
await tab.evaluate_js(f"window.DeckyPluginLoader.notifyUpdates()", False, True, False) await tab.evaluate_js(
f"window.DeckyPluginLoader.notifyUpdates()", False, True, False
)
return await self.get_version() return await self.get_version()
async def version_reloader(self): async def version_reloader(self):
@@ -136,59 +166,65 @@ class Updater:
await self.check_for_updates() await self.check_for_updates()
except: except:
pass pass
await sleep(60 * 60 * 6) # 6 hours await sleep(60 * 60 * 6) # 6 hours
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 = None download_url = self.remoteVer["assets"][0]["browser_download_url"]
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:
if ON_LINUX: logger.debug("Downloading systemd service")
logger.debug("Downloading systemd service") # download the relevant systemd service depending upon branch
# download the relevant systemd service depending upon branch async with web.request(
async with web.request("GET", service_url, ssl=helpers.get_ssl_context(), allow_redirects=True) as res: "GET", service_url, ssl=helpers.get_ssl_context(), allow_redirects=True
logger.debug("Downloading service file") ) 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", encoding="utf-8") as service_file: logger.error(f"Error at %s", exc_info=e)
service_data = service_file.read() with open(
service_data = service_data.replace("${HOMEBREW_FOLDER}", helpers.get_homebrew_path()) path.join(getcwd(), "plugin_loader.service"), "r", encoding="utf-8"
with open(path.join(getcwd(), "plugin_loader.service"), "w", encoding="utf-8") as service_file: ) as service_file:
service_file.write(service_data) service_data = service_file.read()
service_data = service_data.replace(
logger.debug("Saved service file") "${HOMEBREW_FOLDER}", helpers.get_homebrew_path()
logger.debug("Copying service file over current file.") )
shutil.copy(service_file_path, "/etc/systemd/system/plugin_loader.service") with open(
if not os.path.exists(path.join(getcwd(), ".systemd")): path.join(getcwd(), "plugin_loader.service"), "w", encoding="utf-8"
os.mkdir(path.join(getcwd(), ".systemd")) ) as service_file:
shutil.move(service_file_path, path.join(getcwd(), ".systemd")+"/plugin_loader.service") service_file.write(service_data)
logger.debug("Saved service file")
logger.debug("Copying service file over current file.")
shutil.copy(service_file_path, "/etc/systemd/system/plugin_loader.service")
if not os.path.exists(path.join(getcwd(), ".systemd")):
os.mkdir(path.join(getcwd(), ".systemd"))
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(
total = int(res.headers.get('content-length', 0)) "GET", download_url, ssl=helpers.get_ssl_context(), allow_redirects=True
with open(path.join(getcwd(), download_temp_filename), "wb") as out: ) as res:
total = int(res.headers.get("content-length", 0))
# we need to not delete the binary until we have downloaded the new binary!
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):
@@ -196,21 +232,27 @@ class Updater:
raw += len(c) raw += len(c)
new_progress = round((raw / total) * 100) new_progress = round((raw / total) * 100)
if progress != new_progress: if progress != new_progress:
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", encoding="utf-8") as out: with open(
path.join(getcwd(), ".loader.version"), "w", encoding="utf-8"
) as out:
out.write(version) out.write(version)
if ON_LINUX: call(["chmod", "+x", path.join(getcwd(), "PluginLoader")])
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):
await service_restart("plugin_loader") call(["systemctl", "daemon-reload"])
call(["systemctl", "restart", "plugin_loader"])
+58 -77
View File
@@ -3,14 +3,13 @@ import os
from json.decoder import JSONDecodeError from json.decoder import JSONDecodeError
from traceback import format_exc from traceback import format_exc
from asyncio import sleep, start_server, gather, open_connection from asyncio import 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, close_old_tabs from injector import inject_to_tab, get_gamepadui_tab, close_old_tabs
import helpers import helpers
import subprocess
from localplatform import service_stop, service_start
class Utilities: class Utilities:
def __init__(self, context) -> None: def __init__(self, context) -> None:
@@ -31,7 +30,7 @@ class Utilities:
"get_setting": self.get_setting, "get_setting": self.get_setting,
"filepicker_ls": self.filepicker_ls, "filepicker_ls": self.filepicker_ls,
"disable_rdt": self.disable_rdt, "disable_rdt": self.disable_rdt,
"enable_rdt": self.enable_rdt "enable_rdt": self.enable_rdt,
} }
self.logger = getLogger("Utilities") self.logger = getLogger("Utilities")
@@ -41,9 +40,9 @@ class Utilities:
self.rdt_proxy_task = None self.rdt_proxy_task = None
if context: if context:
context.web_app.add_routes([ context.web_app.add_routes(
web.post("/methods/{method_name}", self._handle_server_method_call) [web.post("/methods/{method_name}", self._handle_server_method_call)]
]) )
async def _handle_server_method_call(self, request): async def _handle_server_method_call(self, request):
method_name = request.match_info["method_name"] method_name = request.match_info["method_name"]
@@ -61,12 +60,11 @@ class Utilities:
res["success"] = False res["success"] = False
return web.json_response(res) return web.json_response(res)
async def install_plugin(self, artifact="", name="No name", version="dev", hash=False): async def install_plugin(
self, artifact="", name="No name", version="dev", hash=False
):
return await self.context.plugin_browser.request_plugin_install( return await self.context.plugin_browser.request_plugin_install(
artifact=artifact, artifact=artifact, name=name, version=version, hash=hash
name=name,
version=version,
hash=hash
) )
async def confirm_plugin_install(self, request_id): async def confirm_plugin_install(self, request_id):
@@ -80,13 +78,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() text = await res.text()
return { return {"status": res.status, "headers": dict(res.headers), "body": text}
"status": res.status,
"headers": dict(res.headers),
"body": text
}
async def ping(self, **kwargs): async def ping(self, **kwargs):
return "pong" return "pong"
@@ -95,26 +91,18 @@ class Utilities:
try: try:
result = await inject_to_tab(tab, code, run_async) result = await inject_to_tab(tab, code, run_async)
if "exceptionDetails" in result["result"]: if "exceptionDetails" in result["result"]:
return { return {"success": False, "result": result["result"]}
"success": False,
"result": result["result"]
}
return { return {"success": True, "result": result["result"]["result"].get("value")}
"success": True,
"result": result["result"]["result"].get("value")
}
except Exception as e: except Exception as e:
return { return {"success": False, "result": e}
"success": False,
"result": e
}
async def inject_css_into_tab(self, tab, style): async def inject_css_into_tab(self, tab, style):
try: try:
css_id = str(uuid.uuid4()) css_id = str(uuid.uuid4())
result = await inject_to_tab(tab, result = await inject_to_tab(
tab,
f""" f"""
(function() {{ (function() {{
const style = document.createElement('style'); const style = document.createElement('style');
@@ -122,27 +110,21 @@ class Utilities:
document.head.append(style); document.head.append(style);
style.textContent = `{style}`; style.textContent = `{style}`;
}})() }})()
""", False) """,
False,
)
if "exceptionDetails" in result["result"]: if "exceptionDetails" in result["result"]:
return { return {"success": False, "result": result["result"]}
"success": False,
"result": result["result"]
}
return { return {"success": True, "result": css_id}
"success": True,
"result": css_id
}
except Exception as e: except Exception as e:
return { return {"success": False, "result": e}
"success": False,
"result": e
}
async def remove_css_from_tab(self, tab, css_id): async def remove_css_from_tab(self, tab, css_id):
try: try:
result = await inject_to_tab(tab, result = await inject_to_tab(
tab,
f""" f"""
(function() {{ (function() {{
let style = document.getElementById("{css_id}"); let style = document.getElementById("{css_id}");
@@ -150,22 +132,16 @@ class Utilities:
if (style.nodeName.toLowerCase() == 'style') if (style.nodeName.toLowerCase() == 'style')
style.parentNode.removeChild(style); style.parentNode.removeChild(style);
}})() }})()
""", False) """,
False,
)
if "exceptionDetails" in result["result"]: if "exceptionDetails" in result["result"]:
return { return {"success": False, "result": result}
"success": False,
"result": result
}
return { return {"success": True}
"success": True
}
except Exception as e: except Exception as e:
return { return {"success": False, "result": e}
"success": False,
"result": e
}
async def get_setting(self, key, default): async def get_setting(self, key, default):
return self.context.settings.getSetting(key, default) return self.context.settings.getSetting(key, default)
@@ -174,11 +150,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 service_start(helpers.REMOTE_DEBUGGER_UNIT) await helpers.start_systemd_unit(helpers.REMOTE_DEBUGGER_UNIT)
return True return True
async def disallow_remote_debugging(self): async def disallow_remote_debugging(self):
await service_stop(helpers.REMOTE_DEBUGGER_UNIT) await helpers.stop_systemd_unit(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):
@@ -187,7 +163,7 @@ class Utilities:
# return os.path.getmtime(os.path.join(path, file)) # return os.path.getmtime(os.path.join(path, file))
# return 0 # return 0
# file_names = sorted(os.listdir(path), key=sorter, reverse=True) # TODO provide more sort options # file_names = sorted(os.listdir(path), key=sorter, reverse=True) # TODO provide more sort options
file_names = sorted(os.listdir(path)) # Alphabetical file_names = sorted(os.listdir(path)) # Alphabetical
files = [] files = []
@@ -196,16 +172,15 @@ class Utilities:
is_dir = os.path.isdir(full_path) is_dir = os.path.isdir(full_path)
if is_dir or include_files: if is_dir or include_files:
files.append({ files.append(
"isdir": is_dir, {
"name": file, "isdir": is_dir,
"realpath": os.path.realpath(full_path) "name": file,
}) "realpath": os.path.realpath(full_path),
}
)
return { return {"realpath": os.path.realpath(path), "files": files}
"realpath": os.path.realpath(path),
"files": files
}
# Based on https://stackoverflow.com/a/46422554/13174603 # Based on https://stackoverflow.com/a/46422554/13174603
def start_rdt_proxy(self, ip, port): def start_rdt_proxy(self, ip, port):
@@ -215,10 +190,10 @@ class Utilities:
writer.write(await reader.read(2048)) writer.write(await reader.read(2048))
finally: finally:
writer.close() writer.close()
async def handle_client(local_reader, local_writer): async def handle_client(local_reader, local_writer):
try: try:
remote_reader, remote_writer = await open_connection( remote_reader, remote_writer = await open_connection(ip, port)
ip, port)
pipe1 = pipe(local_reader, remote_writer) pipe1 = pipe(local_reader, remote_writer)
pipe2 = pipe(remote_reader, local_writer) pipe2 = pipe(remote_reader, local_writer)
await gather(pipe1, pipe2) await gather(pipe1, pipe2)
@@ -239,11 +214,14 @@ class Utilities:
self.stop_rdt_proxy() self.stop_rdt_proxy()
ip = self.context.settings.getSetting("developer.rdt.ip", None) ip = self.context.settings.getSetting("developer.rdt.ip", None)
if ip != None: if ip is not None:
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(
script = """ "GET", "http://" + ip + ":8097", ssl=helpers.get_ssl_context()
)
script = (
"""
if (!window.deckyHasConnectedRDT) { if (!window.deckyHasConnectedRDT) {
window.deckyHasConnectedRDT = true; window.deckyHasConnectedRDT = true;
// This fixes the overlay when hovering over an element in RDT // This fixes the overlay when hovering over an element in RDT
@@ -251,10 +229,13 @@ class Utilities:
enumerable: true, enumerable: true,
configurable: true, configurable: true,
get: function() { get: function() {
return (GamepadNavTree?.m_context?.m_controller || FocusNavController)?.m_ActiveContext?.ActiveWindow || window; return FocusNavController?.m_ActiveContext?.ActiveWindow || window;
} }
}); });
""" + await res.text() + "\n}" """
+ 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
@@ -265,7 +246,7 @@ class Utilities:
await close_old_tabs() 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)
except Exception: except Exception:
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())
+1 -2
View File
@@ -9,8 +9,7 @@ Restart=always
ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
WorkingDirectory=${HOMEBREW_FOLDER}/services WorkingDirectory=${HOMEBREW_FOLDER}/services
KillSignal=SIGKILL KillSignal=SIGKILL
Environment=UNPRIVILEGED_PATH=${HOMEBREW_FOLDER} Environment=PLUGIN_PATH=${HOMEBREW_FOLDER}/plugins
Environment=PRIVILEGED_PATH=${HOMEBREW_FOLDER}
Environment=LOG_LEVEL=DEBUG Environment=LOG_LEVEL=DEBUG
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+1 -2
View File
@@ -9,8 +9,7 @@ Restart=always
ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
WorkingDirectory=${HOMEBREW_FOLDER}/services WorkingDirectory=${HOMEBREW_FOLDER}/services
KillSignal=SIGKILL KillSignal=SIGKILL
Environment=UNPRIVILEGED_PATH=${HOMEBREW_FOLDER} Environment=PLUGIN_PATH=${HOMEBREW_FOLDER}/plugins
Environment=PRIVILEGED_PATH=${HOMEBREW_FOLDER}
Environment=LOG_LEVEL=INFO Environment=LOG_LEVEL=INFO
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

-162
View File
@@ -1,162 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 8.9 KiB

+13 -13
View File
@@ -12,28 +12,28 @@
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-commonjs": "^21.1.0", "@rollup/plugin-commonjs": "^21.1.0",
"@rollup/plugin-image": "^3.0.2", "@rollup/plugin-image": "^3.0.1",
"@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.5.0", "@rollup/plugin-typescript": "^8.3.3",
"@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.1", "@types/webpack": "^5.28.0",
"husky": "^8.0.3", "husky": "^8.0.1",
"import-sort-style-module": "^6.0.0", "import-sort-style-module": "^6.0.0",
"inquirer": "^8.2.5", "inquirer": "^8.2.4",
"prettier": "^2.8.7", "prettier": "^2.7.1",
"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.79.1", "rollup": "^2.76.0",
"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.5.0", "tslib": "^2.4.0",
"typescript": "^4.9.5" "typescript": "^4.7.4"
}, },
"importSort": { "importSort": {
".js, .jsx, .ts, .tsx": { ".js, .jsx, .ts, .tsx": {
@@ -42,10 +42,10 @@
} }
}, },
"dependencies": { "dependencies": {
"decky-frontend-lib": "3.20.5", "decky-frontend-lib": "^3.18.10",
"react-file-icon": "^1.3.0", "react-file-icon": "^1.2.0",
"react-icons": "^4.8.0", "react-icons": "^4.4.0",
"react-markdown": "^8.0.6", "react-markdown": "^8.0.3",
"remark-gfm": "^3.0.1" "remark-gfm": "^3.0.1"
} }
} }
+736 -772
View File
File diff suppressed because it is too large Load Diff
+1 -18
View File
@@ -6,7 +6,6 @@ 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;
@@ -16,7 +15,6 @@ 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;
@@ -28,7 +26,6 @@ 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,
@@ -47,11 +44,6 @@ 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();
@@ -86,7 +78,6 @@ 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;
} }
@@ -115,18 +106,10 @@ 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={{ value={{ ...publicDeckyState, setIsLoaderUpdating, setVersionInfo, setActivePlugin, closeActivePlugin }}
...publicDeckyState,
setIsLoaderUpdating,
setVersionInfo,
setActivePlugin,
closeActivePlugin,
setPluginOrder,
}}
> >
{children} {children}
</DeckyStateContext.Provider> </DeckyStateContext.Provider>
+2 -2
View File
@@ -1,4 +1,4 @@
import { Focusable, Navigation } from 'decky-frontend-lib'; import { Focusable, Router } 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?.();
Navigation.NavigateToExternalWeb(aRef.current!.href); Router.NavigateToExternalWeb(aRef.current!.href);
}} }}
style={{ display: 'inline' }} style={{ display: 'inline' }}
> >
+3 -13
View File
@@ -7,27 +7,17 @@ import {
scrollClasses, scrollClasses,
staticClasses, staticClasses,
} from 'decky-frontend-lib'; } from 'decky-frontend-lib';
import { VFC, useEffect, useState } from 'react'; import { VFC } 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, pluginOrder, setActivePlugin, closeActivePlugin } = useDeckyState(); const { plugins, updates, activePlugin, 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}>
@@ -46,7 +36,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>
{pluginList {plugins
.filter((p) => p.content) .filter((p) => p.content)
.map(({ name, icon }) => ( .map(({ name, icon }) => (
<PanelSectionRow key={name}> <PanelSectionRow key={name}>
@@ -4,11 +4,15 @@ const QuickAccessVisibleState = createContext<boolean>(true);
export const useQuickAccessVisible = () => useContext(QuickAccessVisibleState); export const useQuickAccessVisible = () => useContext(QuickAccessVisibleState);
export const QuickAccessVisibleStateProvider: FC<{ initial: boolean; tab: any }> = ({ children, initial, tab }) => { export const QuickAccessVisibleStateProvider: FC<{ initial: boolean; setter: ((val: boolean) => {}[]) | never[] }> = ({
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 but i can't think of a better way to do this // hack to use an array as a "pointer" to pass the setter up the tree
tab.qAMVisibilitySetter = setVisible; setter[0] = setVisible;
if (initial != prev) { if (initial != prev) {
setPrev(initial); setPrev(initial);
setVisible(initial); setVisible(initial);
@@ -29,10 +29,10 @@ const PluginInstallModal: FC<PluginInstallModalProps> = ({ artifact, version, ha
strTitle={`Install ${artifact}`} strTitle={`Install ${artifact}`}
strOKButtonText={loading ? 'Installing' : 'Install'} strOKButtonText={loading ? 'Installing' : 'Install'}
> >
Are you sure you want to install {artifact} {hash == 'False' ? (
{version ? ` ${version}` : ''}? <h3 style={{ color: 'red' }}>!!!!NO HASH PROVIDED!!!!</h3>
{hash == 'False' && ( ) : (
<span style={{ color: 'red' }}> This plugin does not have a hash, you are installing it at your own risk.</span> `Are you sure you want to install ${artifact} ${version}?`
)} )}
</ConfirmModal> </ConfirmModal>
); );
@@ -134,15 +134,7 @@ const FilePicker: FunctionComponent<FilePickerProps> = ({
)} )}
</div> </div>
)} )}
<span {file.name}
style={{
textOverflow: 'ellipsis',
overflow: 'hidden',
textAlign: 'left',
}}
>
{file.name}
</span>
</div> </div>
</DialogButton> </DialogButton>
); );
@@ -4,6 +4,13 @@ import Logger from '../../../../logger';
const logger = new Logger('LibraryPatch'); const logger = new Logger('LibraryPatch');
declare global {
interface Window {
SteamClient: any;
appDetailsStore: any;
}
}
let patch: Patch; let patch: Patch;
function rePatch() { function rePatch() {
@@ -13,9 +20,7 @@ function rePatch() {
const details = window.appDetailsStore.GetAppDetails(appid); const details = window.appDetailsStore.GetAppDetails(appid);
logger.debug('game details', details); logger.debug('game details', details);
// strShortcutStartDir // strShortcutStartDir
const file = await window.DeckyPluginLoader.openFilePicker( const file = await window.DeckyPluginLoader.openFilePicker(details.strShortcutStartDir.replaceAll('"', ''));
details?.strShortcutStartDir.replaceAll('"', '') || '/',
);
logger.debug('user selected', file); 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('/');
@@ -1,109 +1,64 @@
import { import { DialogBody, Field, TextField, Toggle } from 'decky-frontend-lib';
DialogBody, import { useRef } from 'react';
DialogButton, import { FaReact, FaSteamSymbol } from 'react-icons/fa';
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'; 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> <DialogBody>
<DialogControlsSection> <RemoteDebuggingSettings />
<DialogControlsSectionHeader>Third-Party Plugins</DialogControlsSectionHeader> <Field
<Field label="Install Plugin from ZIP File" icon={<FaFileArchive style={{ display: 'block' }} />}> label="Enable Valve Internal"
<DialogButton onClick={installFromZip}>Browse</DialogButton> description={
</Field> <span style={{ whiteSpace: 'pre-line' }}>
<Field Enables the Valve internal developer menu.{' '}
label="Install Plugin from URL" <span style={{ color: 'red' }}>Do not touch anything in this menu unless you know what it does.</span>
description={<TextField label={'URL'} value={pluginURL} onChange={(e) => setPluginURL(e?.target.value)} />} </span>
icon={<FaLink style={{ display: 'block' }} />} }
> icon={<FaSteamSymbol style={{ display: 'block' }} />}
<DialogButton disabled={pluginURL.length == 0} onClick={() => installFromURL(pluginURL)}> >
Install <Toggle
</DialogButton> value={enableValveInternal}
</Field> onChange={(toggleValue) => {
</DialogControlsSection> setEnableValveInternal(toggleValue);
<DialogControlsSection> setShowValveInternal(toggleValue);
<DialogControlsSectionHeader>Other</DialogControlsSectionHeader> }}
<RemoteDebuggingSettings /> />
<Field </Field>
label="Enable Valve Internal" <Field
description={ label="Enable React DevTools"
description={
<>
<span style={{ whiteSpace: 'pre-line' }}> <span style={{ whiteSpace: 'pre-line' }}>
Enables the Valve internal developer menu.{' '} Enables connection to a computer running React DevTools. Changing this setting will reload Steam. Set the
<span style={{ color: 'red' }}>Do not touch anything in this menu unless you know what it does.</span> IP address before enabling.
</span> </span>
} <br />
icon={<FaSteamSymbol style={{ display: 'block' }} />} <br />
> <div ref={textRef}>
<Toggle <TextField label={'IP'} value={reactDevtoolsIP} onChange={(e) => setReactDevtoolsIP(e?.target.value)} />
value={enableValveInternal} </div>
onChange={(toggleValue) => { </>
setEnableValveInternal(toggleValue); }
setShowValveInternal(toggleValue); icon={<FaReact style={{ display: 'block' }} />}
}} >
/> <Toggle
</Field> value={reactDevtoolsEnabled}
<Field disabled={reactDevtoolsIP == ''}
label="Enable React DevTools" onChange={(toggleValue) => {
description={ setReactDevtoolsEnabled(toggleValue);
<> setShouldConnectToReactDevTools(toggleValue);
<span style={{ whiteSpace: 'pre-line' }}> }}
Enables connection to a computer running React DevTools. Changing this setting will reload Steam. Set />
the IP address before enabling. </Field>
</span>
<br />
<br />
<div ref={textRef}>
<TextField label={'IP'} value={reactDevtoolsIP} onChange={(e) => setReactDevtoolsIP(e?.target.value)} />
</div>
</>
}
icon={<FaReact style={{ display: 'block' }} />}
>
<Toggle
value={reactDevtoolsEnabled}
// disabled={reactDevtoolsIP == ''}
onChange={(toggleValue) => {
setReactDevtoolsEnabled(toggleValue);
setShouldConnectToReactDevTools(toggleValue);
}}
/>
</Field>
</DialogControlsSection>
</DialogBody> </DialogBody>
); );
} }
@@ -6,7 +6,6 @@ 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';
@@ -15,6 +14,7 @@ import { useEffect, useState } from 'react';
import { FaExclamation } 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 || 'Invalid Update Name'}</h1> <h1>{versionInfo?.all?.[id]?.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>
@@ -1,5 +1,15 @@
import { DialogBody, DialogControlsSection, DialogControlsSectionHeader, Field, Toggle } from 'decky-frontend-lib'; import {
DialogBody,
DialogButton,
DialogControlsSection,
DialogControlsSectionHeader,
Field,
TextField,
Toggle,
} from 'decky-frontend-lib';
import { useState } from 'react';
import { installFromURL } from '../../../../store';
import { useDeckyState } from '../../../DeckyState'; import { useDeckyState } from '../../../DeckyState';
import BranchSelect from './BranchSelect'; import BranchSelect from './BranchSelect';
import StoreSelect from './StoreSelect'; import StoreSelect from './StoreSelect';
@@ -12,6 +22,7 @@ export default function GeneralSettings({
isDeveloper: boolean; isDeveloper: boolean;
setIsDeveloper: (val: boolean) => void; setIsDeveloper: (val: boolean) => void;
}) { }) {
const [pluginURL, setPluginURL] = useState('');
const { versionInfo } = useDeckyState(); const { versionInfo } = useDeckyState();
return ( return (
@@ -35,6 +46,14 @@ export default function GeneralSettings({
}} }}
/> />
</Field> </Field>
<Field
label="Install plugin from URL"
description={<TextField label={'URL'} value={pluginURL} onChange={(e) => setPluginURL(e?.target.value)} />}
>
<DialogButton disabled={pluginURL.length == 0} onClick={() => installFromURL(pluginURL)}>
Install
</DialogButton>
</Field>
</DialogControlsSection> </DialogControlsSection>
<DialogControlsSection> <DialogControlsSection>
<DialogControlsSectionHeader>About</DialogControlsSectionHeader> <DialogControlsSectionHeader>About</DialogControlsSectionHeader>
@@ -2,116 +2,24 @@ import {
DialogBody, DialogBody,
DialogButton, DialogButton,
DialogControlsSection, DialogControlsSection,
GamepadEvent, Focusable,
Menu, Menu,
MenuItem, MenuItem,
ReorderableEntry,
ReorderableList,
showContextMenu, showContextMenu,
} from 'decky-frontend-lib'; } from 'decky-frontend-lib';
import { useEffect, useState } from 'react'; import { useEffect } from 'react';
import { FaDownload, FaEllipsisH, FaRecycle } from 'react-icons/fa'; import { FaDownload, FaEllipsisH } from 'react-icons/fa';
import { StorePluginVersion, getPluginList, requestPluginInstall } from '../../../../store'; import { 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, pluginOrder, setPluginOrder } = useDeckyState(); const { plugins, updates } = 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>
@@ -120,17 +28,52 @@ 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 (
<DialogBody> <DialogBody>
<DialogControlsSection> <DialogControlsSection>
<ReorderableList<PluginData> entries={pluginEntries} onSave={onSave} interactables={PluginInteractables} /> <ul style={{ listStyleType: 'none', padding: '0' }}>
{plugins.map(({ name, version }) => {
const update = updates?.get(name);
return (
<li style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', paddingBottom: '10px' }}>
<span>
{name} <span style={{ opacity: '50%' }}>{'(' + version + ')'}</span>
</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>
</DialogControlsSection> </DialogControlsSection>
</DialogBody> </DialogBody>
); );
+1 -1
View File
@@ -15,7 +15,7 @@ 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('Store'); const logger = new Logger('FilePicker');
const StorePage: FC<{}> = () => { const StorePage: FC<{}> = () => {
const [currentTabRoute, setCurrentTabRoute] = useState<string>('browse'); const [currentTabRoute, setCurrentTabRoute] = useState<string>('browse');
-1
View File
@@ -56,7 +56,6 @@ 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();
}; };
-6
View File
@@ -169,12 +169,6 @@ 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() {
+5 -3
View File
@@ -123,9 +123,11 @@ 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;
const potentialSettingsRootString = if (
ret.props.children.props.children[idx]?.props?.children?.[0]?.type?.type?.toString() || ''; ret.props.children.props.children[idx]?.props?.children?.[0]?.type?.type
if (potentialSettingsRootString?.includes('Settings.Root()')) { ?.toString()
?.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) => {
+1 -1
View File
@@ -7,6 +7,6 @@ export function deinitSteamFixes() {
} }
export async function initSteamFixes() { export async function initSteamFixes() {
fixes.push(await reloadFix()); fixes.push(reloadFix());
fixes.push(await restartFix()); fixes.push(await restartFix());
} }
+2 -9
View File
@@ -1,17 +1,10 @@
import { getFocusNavController, sleep } from 'decky-frontend-lib';
import Logger from '../logger'; import Logger from '../logger';
const logger = new Logger('ReloadSteamFix'); const logger = new Logger('ReloadSteamFix');
declare global { export default function reloadFix() {
var GamepadNavTree: any;
}
export default async function reloadFix() {
// Hack to unbreak the ui when reloading it // Hack to unbreak the ui when reloading it
await sleep(4000); if (window.FocusNavController?.m_rgAllContexts?.length == 0) {
if (getFocusNavController()?.m_rgAllContexts?.length == 0) {
SteamClient.URL.ExecuteSteamURL('steam://open/settings'); SteamClient.URL.ExecuteSteamURL('steam://open/settings');
logger.log('Applied UI reload fix.'); logger.log('Applied UI reload fix.');
} }
+7
View File
@@ -4,6 +4,13 @@ import Logger from '../logger';
const logger = new Logger('RestartSteamFix'); const logger = new Logger('RestartSteamFix');
declare global {
interface Window {
SteamClient: any;
appDetailsStore: any;
}
}
let patch: Patch; let patch: Patch;
function rePatch() { function rePatch() {
+9 -10
View File
@@ -35,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 >= 65) { if (iters >= 55) {
// currently 45 // currently 45
return null; return null;
} }
@@ -128,23 +128,22 @@ 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?.qAMVisibilitySetter) tab?.qAMVisibilitySetter(visible); if (tab?.decky) tab.panel.props.setter[0](visible);
} }
return; return;
} }
for (const { title, icon, content, id } of this.tabs) { for (const { title, icon, content, id } of this.tabs) {
const tab: any = { existingTabs.push({
key: id, key: id,
title, title,
tab: icon, tab: icon,
decky: true, decky: true,
}; panel: (
tab.panel = ( <QuickAccessVisibleStateProvider initial={visible} setter={[]}>
<QuickAccessVisibleStateProvider initial={visible} tab={tab}> {content}
{content} </QuickAccessVisibleStateProvider>
</QuickAccessVisibleStateProvider> ),
); });
existingTabs.push(tab);
} }
} }
} }
+3 -7
View File
@@ -40,15 +40,11 @@ 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 >= 65) { if (iters >= 50) {
// currently 65 // currently 40
return null; return null;
} }
if ( if (currentNode?.memoizedProps?.className?.startsWith?.('toastmanager_ToastPlaceholder')) {
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;
} }
+7
View File
@@ -0,0 +1,7 @@
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 -1
View File
@@ -1,7 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"module": "ESNext", "module": "ESNext",
"target": "ES2021", "target": "ES2020",
"jsx": "react", "jsx": "react",
"jsxFactory": "window.SP_REACT.createElement", "jsxFactory": "window.SP_REACT.createElement",
"jsxFragmentFactory": "window.SP_REACT.Fragment", "jsxFragmentFactory": "window.SP_REACT.Fragment",
-201
View File
@@ -1,201 +0,0 @@
"""
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)
-173
View File
@@ -1,173 +0,0 @@
"""
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`."""