Compare commits

...

77 Commits

Author SHA1 Message Date
Travis Lane a07e4d6fe6 fix: version is no longer missing from plugin list (#417) 2023-04-10 16:47:59 -07:00
Party Wumpus 4ab7d97ab2 Various readme changes (#422)
* various installation clarifications

* Update README.md

* Update README.md

* Update README.md

* rip crankshaft, hope you come back one day

* Update README.md

* oops i linked to PartyWumpus/decky-loader by accident

i fix
2023-04-07 11:04:08 -07:00
AAGaming 15a6f7fdb8 fix UI reloading on latest beta to prevent freeze when updating Decky 2023-04-06 13:00:54 -04:00
AAGaming 7d2cff8745 fix two missing arguments, fixing reordering of newly installed plugins (#412) 2023-04-04 14:52:32 -04:00
Party Wumpus ee5ed3faf0 Make readme's download button into an svg (#413) 2023-04-04 12:36:30 -04:00
Travis Lane 0f36e87cce Add plugin reordering (#378)
* feat: started work on saving plugin order

* feat: implemented local ReorderableList

* feat: reoder complete except for usage of DFL

* switched to using dfl reorderableList

* fix: added missing file and removed frag

* updated to newest dfl

* Update defsettings.json

* fix: plugin order was missing on init

* fix: now await pluginOrder

* fix: moved the plugin-order load to plugin-loader

* chore: v6 and dfl bump
2023-04-03 14:21:31 -07:00
suchmememanyskill fd325ef1cc Add cross-platform support to decky (#387)
* Import generic watchdog observer over platform specific import

* Use os.path rather than genericpath

* Split off socket management in plugin.py

* Don't specify multiprocessing start type

Default on linux is already fork

* Move all platform-specific functions to seperate files

TODO: make plugin.py platform agnostic

* fix import

* add backwards compat to helpers.py

* add backwards compatibility to helpers.py harder

* Testing autobuild for win

* Testing autobuild for win, try 2

* Testing autobuild for win, try 3

* Testing autobuild for win, try 4

* Create the plugins folder before attempting to use it

* Implement win get_username()

* Create win install script

* Fix branch guess from version

* Create .loader.version in install script

* Add .cmd shim to facilitate auto-restarts

* Properly fix branch guess from version

* Fix updater on windows

* Try 2 of fixing updates for windows

* Test

* pain

* Update install script

* Powershell doesn't believe in utf8

* Powershell good

* add ON_LINUX variable to localplatform

* Fix more merge issues

* test

* Move custom imports to main.py

* Move custom imports to after __main__ check 

Due to windows' default behaviour being spawn, it will spawn a new process and thus import into sys.path multiple times

* Log errors in get_system_pythonpaths() and get_loader_version() + 

split get_system_pythonpaths() on newline

* Remove whitespace in result of get_system_pythonpaths()

* use python3 on linux and python on windows in get_system_pythonpaths()

* Remove fork-specific urls

* Fix MIME types not working on Windows
2023-03-21 17:37:23 -07:00
TrainDoctor faf46ba533 Update edit-check.yml 2023-03-09 16:32:54 -08:00
TrainDoctor 94ec434eae Update edit-check.yml 2023-03-09 16:31:43 -08:00
TrainDoctor a223efd6f5 Update edit-check.yml 2023-03-09 10:24:01 -08:00
suchmememanyskill 395e45167d Shared Ctx tab rename to SharedJSContext (#395) 2023-03-09 18:58:19 +01:00
TrainDoctor 0dd0d9f4bd Add CI to automatically update plugin stub in template 2023-03-05 16:23:17 -08:00
geeksville 3e5404abdd fix #390 the plugin_directory argument to import_plugin was incorrect (#391) 2023-03-05 14:30:26 -08:00
Beebles 46abc5a266 Fix QAM And Toaster Injection for Mar 02 Beta (#388) 2023-03-01 20:20:31 -08:00
TrainDoctor 88e1e9b869 Update README.md 2023-02-25 10:30:20 -08:00
TrainDoctor fc0089f7a5 Update bug_report.yml 2023-02-25 07:26:58 -08:00
AAGaming d335562328 update NavigateToExternalWeb in Markdown to use Navigation 2023-02-22 22:17:28 -05:00
AAGaming f9624a0859 how did this ever happen 2023-02-22 22:03:19 -05:00
AAGaming 97bb3fa4c8 Fix loader on feb 22 2023 beta 2023-02-22 22:00:30 -05:00
TrainDoctor 611245aec9 Update bug_report.yml 2023-02-22 17:38:46 -08:00
suchmememanyskill e1807e8c75 General Backend Fixes (#373)
* General Backend Fixes

* Ajust helpers.get_loader_version() to never throw an exception
2023-02-19 16:37:26 -08:00
TrainDoctor b94cfe32d9 Update README.md 2023-02-19 16:22:26 -08:00
Philipp Richter f1e679c3fb Expose a 'decky_plugin' module to decky plugins (#353)
* Expose a 'decky_plugin' module to decky plugins

* expose decky user home path
* support 'py_modules' python modules in plugins
* allow for a '_migration' method in plugins to have an explicit file
  moving step

* Expose the plugin python module as .pyi stub interface

* Expose system and user python paths to plugins
2023-02-19 14:42:55 -08:00
Beebles e1b138bcbd Fix fullscreen route inject issues caused by Feb. 17th beta. (#372)
* remove gamepad ui

* Refactor
2023-02-17 17:27:20 -08:00
Kevin Hester c6be8f6c14 Minor README fix for build instructions. (#370) 2023-02-17 14:10:03 -08:00
TrainDoctor ac086cf59e Update README.md 2023-02-08 18:43:33 -08:00
Marco Rodolfi 3e120ea312 Fix class name shenanigans for toast notification (#366)
* Fix class name shenanigans for  toast notification

* Corrected number of iterations
2023-02-06 17:30:44 -08:00
Sky Leite 0b718daa47 Add lint job to build workflow (#363)
* Add lint job to build workflow

* Add prettier-plugin-import-sort

* Install prettier plugins before linting

* Use lint script from package.json

* Move linters to separate workflow

* Remove Python and Shell linters

* Remove popd

* Test that prettier properly fails the lint job
2023-02-03 19:40:29 -08:00
EMERALD 0929b9c5cb Specify linux/amd64 Docker architecture (#356) 2023-02-01 17:17:24 -08:00
EMERALD 43b2269ea7 Fix UI inconsistencies, various improvements (#357)
* Make version gray in plugin list

* Settings/store icons together & plugin list fix

* Navigation name/icon improvements

* Decky settings overhaul and other fixes

- Revert the tab icon to a plug
- Rename DeckyFlat function to DeckyIcon
- Add DialogBody to settings pages to improve scrolling
- Add remote debugging settings to the developer settings
- Fix React devtools interactions to work more easily
- Add spacing to React devtools description
- Specify Decky vs. plugin store
- Compact version information by update button
- Add current version to bottom of settings
- Remove unnecessary settings icons
- Change CEF debugger icon to Chrome (bug icon too generic, is Chromium)
- Make buttons/dropdowns in settings have fixed width
- Make download icon act/appear similar to Valve's for Deck

* Final UI adjustments

* Switch plugin settings icon to plug
2023-02-01 17:16:42 -08:00
Party Wumpus 0c4e27cd34 [readme] add installer issue to common issues (#359) 2023-02-01 12:31:44 -08:00
TrainDoctor 36cf85b08a Comment out un-needed pprint usage 2023-01-29 15:27:52 -08:00
TrainDoctor 994da868af Add python logging to browser and plugin 2023-01-29 15:16:16 -08:00
TrainDoctor 2e53fb217a Add better handling for unloading of plugins 2023-01-29 13:59:02 -08:00
Philipp Richter c2b76d9099 Expose useful env vars to plugin processes (#349)
* recommended paths for storing data
* improve helper functions
2023-01-22 16:54:05 -08:00
TrainDoctor c05e8f9ae0 Update build.yml 2023-01-22 16:33:06 -08:00
TrainDoctor 2dce0646bd Update README.md 2023-01-22 16:29:27 -08:00
Beebles 6569f1b268 Fix http_request not allowing bodys (#352) 2023-01-22 14:33:26 -08:00
EMERALD 3ebaac6752 Store and plugin installation visual improvements (#343)
* Redesign store, add comments for filtering

* Improve installation/uninstallation modals

* Fix store comment to be easier to fix

* Add source code info to about page
2023-01-19 18:00:42 -08:00
dependabot[bot] cbbd564860 Bump certifi from 2022.6.15 to 2022.12.7 (#345)
Bumps [certifi](https://github.com/certifi/python-certifi) from 2022.6.15 to 2022.12.7.
- [Release notes](https://github.com/certifi/python-certifi/releases)
- [Commits](https://github.com/certifi/python-certifi/compare/2022.06.15...2022.12.07)

---
updated-dependencies:
- dependency-name: certifi
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-19 17:54:50 -08:00
TrainDoctor 635edf7f5b fix releases being called prereleases 2023-01-17 15:37:43 -08:00
Nox 1b6e18bcb3 Updated store CSS (#305)
* PluginCard Store CSS Update

* Fixing CSS

* Updated

* Removed padding
2023-01-16 14:43:16 -08:00
AAGaming 0ad0016c62 move the chown 2023-01-16 14:44:16 -05:00
AAGaming a2716449f9 fix missing await on chown_plugin_dir 2023-01-16 14:17:27 -05:00
AAGaming 649eed89c9 bump dfl 2023-01-16 09:13:30 -05:00
AAGaming 83680fffa2 indicate to DFL that the router has shim applied 2023-01-16 09:12:52 -05:00
AAGaming d695b90baf fix React DevTools again 2023-01-15 21:22:54 -05:00
TrainDoctor 5fdcc56409 Aa/bump dfl navigation fix jan2023 (#341)
* fix React DevTools

* bump DFL to fix Navigation

* Bump DFL and add shims

* fix shims not applying due to timing issue

Co-authored-by: AAGaming <aa@mail.catvibers.me>
2023-01-15 17:40:47 -08:00
Party Wumpus 915997d149 [readme] change terminal commands to point at decky-installer (#342)
* update readme to point at decky-installer for konsole scripts

* test change to make dark/light theme work properly

* Update README.md

* Update README.md

* revert <picture> changes as they didn't actually work
2023-01-14 07:49:23 -08:00
Party Wumpus e8b4c4a307 Fix the download button (#330)
* Remove .desktop file from build.yml

* Make download button link to decky-installer repo

* Point download button to the .desktop file not the .sh file 

woops

* Delete decky_installer.desktop

* Delete user_install_script.sh
2023-01-09 10:16:20 -08:00
Party Wumpus e92b66068a Use the new installer in the readme instructions (#324) 2023-01-08 13:16:44 -08:00
AAGaming b72b327610 Fix reloading UI on updates and restarting steam (#303) 2023-01-07 17:33:28 -08:00
Party Wumpus b8fdff8093 Add feature requests as an issue template (#318)
* Create feature_request.yml

* Update feature_request.yml
2023-01-06 09:23:05 -08:00
AAGaming 880b4c2f8f maybe working fix for jan 05 beta (#316) 2023-01-05 20:00:48 -08:00
TrainDoctor 34af340009 Update config.yml 2023-01-05 18:50:10 -08:00
Party Wumpus 80b6115f6f User Friendlier Installer (#297)
* Add files via upload

* Rename EasierInstallScript.sh to user_install_script.sh

* Add files via upload

* change so it works on deck instead of my desktop

* Update decky_installer.desktop

* make auto password setter work without the password

* Update user_install_script.sh

* make installer exit properly if user does not accept temp password

* Update user_install_script.sh

* add uninstall option

* Update user_install_script.sh

* Update user_install_script.sh

* Update user_install_script.sh

* "optimisation"

* Update user_install_script.sh

* Add sizing to all zenity prompts

* "optimization" part 2

* "Program now runs 50% faster"

:)

* Update user_install_script.sh

* Update user_install_script.sh

* Update user_install_script.sh

* Change text in branch selection in installer

'Select Branch' if choosing between release and prerelease
'Select Option' if choosing between release, prerelease and uninstall

* .desktop file points at where script is going to be

* add comments

* Change "installing" to "uninstalling"

* change it to ask for "sudo/admin" password

* Add secondary loading bar for download progress

Shamelessly stolen (with permission) from emudeck, who stole it from a random blog
No I don't know how that line works, and I don't think I want to.

* Make uninstaller tell user they can exit

* add default text to the download bar just in case

* silence script download

* silence password check
2023-01-02 08:52:11 -08:00
Party Wumpus 3bed83697e Add cef debugging to the installer scripts (#310)
* Update install_prerelease.sh

* Update install_release.sh
2023-01-02 08:46:46 -08:00
Party Wumpus 0ffef6e4bf Better bug report format (#312)
* Add files via upload

* Delete bug_report.md

* Update bug_report.yml

* Update bug_report.yml

* Update bug_report.yml

* Update bug_report.yml

* Update bug_report.yml

* Update bug_report.yml

* Update bug_report.yml
2023-01-02 08:45:42 -08:00
AAGaming 8810a014f3 somehow accidentally left this in 2022-12-29 13:11:11 -05:00
AAGaming 385552451b shut down steam instead of restarting it to avoid broken CEF debugger (gamescope will restart stean for us instead) 2022-12-28 12:24:28 -05:00
AAGaming c2c9d11c66 fix broken valveInternal when on a multi-user deck 2022-12-28 12:23:42 -05:00
Nik 0474095a40 Potentially fix locale issues (#284) 2022-12-16 06:23:04 -08:00
AAGaming 346f80beb3 bump DFL to fix modals, Router -> Navigation in some places 2022-12-15 21:16:22 -05:00
TrainDoctor 2a6bf75f02 Move back to python 3.10.2 in CI 2022-12-10 15:26:51 -08:00
jurassicplayer f73918c902 feat(MoreCustomizableToasts): Allow plugin developers to customize some toast properties (#268)
* Use settingsStore directly

* Change toast etype, add showToast/playSound

* Update DFL and rebase
2022-12-10 15:09:21 -08:00
TrainDoctor ea35af2050 Update build.yml 2022-12-08 15:18:44 -08:00
NGnius (Graham) 6232e3da58 Add custom CDN support for custom stores (#269)
* Add custom CDN support for custom stores

* Update Python for CI
2022-12-07 16:27:32 -08:00
TrainDoctor 35e46f9ccb Update build.yml 2022-12-07 14:31:09 -08:00
TrainDoctor 2b9a80c151 Update install_prerelease.sh 2022-12-04 19:05:29 -08:00
TrainDoctor a90ed38c89 Update install_release.sh 2022-12-04 19:05:16 -08:00
TrainDoctor 3653cf5640 Update plugin_loader-release.service 2022-12-04 19:05:01 -08:00
TrainDoctor 0db45ca71e Update plugin_loader-prerelease.service 2022-12-04 19:04:46 -08:00
AAGaming 16681fabb5 fix http requests 2022-11-19 22:33:51 -05:00
AAGaming c210523a22 fix handleWarning in rollup config 2022-11-19 20:07:08 -05:00
Marco Rodolfi 5d8601347a Fix for wrong path for settings json files (#258)
Co-authored-by: AAGaming <aa@mail.catvibers.me>
2022-11-19 19:34:38 -05:00
AAGaming 1e02fcf394 fix broken trycatch causing occasional injection failures 2022-11-19 19:22:30 -05:00
TrainDoctor f923306a7f Update issue templates 2022-11-19 14:19:33 -08:00
67 changed files with 3243 additions and 1520 deletions
+74
View File
@@ -0,0 +1,74 @@
name: Bug report
description: File a bug/issue
title: "[BUG] <title>"
labels: [bug]
body:
- type: checkboxes
id: low-effort-checks
attributes:
label: Please confirm
description: Issues without all checks may be ignored/closed.
options:
- label: I have searched existing issues
- label: This issue is not a duplicate of an existing one
- label: I have checked the [common issues section in the readme file](https://github.com/SteamDeckHomebrew/decky-loader#-common-issues)
- label: I have attached logs to this bug report (failure to include logs will mean your issue will not be responded too).
- type: textarea
attributes:
label: Bug Report Description
description: A clear and concise description of what the bug is and if possible, the steps you used to get to the bug. If appropriate, include screenshots or videos.
placeholder: |
When I try to use ...
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: true
- type: textarea
attributes:
label: Expected Behaviour
description: A brief description of the expected behavior.
placeholder: It should be ...
validations:
required: true
- type: input
attributes:
label: SteamOS version
# description: Can be found with `uname -a`
# placeholder: "Linux steamdeck 5.13.0-valve36-1-neptune #1 SMP PREEMPT Mon, 19 Dec 2022 23:39:41 +0000 x86_64 GNU/Linux"
placeholder: "SteamOS 3.4.3 Stable"
validations:
required: true
- type: dropdown
attributes:
label: Selected Update Channel
description: Which branch of Decky are you on?
multiple: false
options:
- Stable
- Prerelease
validations:
required: true
- type: input
attributes:
label: Have you modified the read-only filesystem at any point?
description: Describe how here, if you haven't done anything you can leave this blank
placeholder: Yes, I've installed neofetch via pacman.
validations:
required: false
- type: textarea
attributes:
label: Logs
description: Please reboot your deck (if possible) when attempting to recreate the issue, then run ``cd ~ && journalctl -b0 -u plugin_loader.service > deckylog.txt``. This will save the log file to ``~`` aka ``/home/deck``. Please upload the file here
placeholder: deckylog.txt
validations:
required: true
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Steam Deck Homebrew Discord Server
url: https://discord.gg/ZU74G2NJzk
about: Please ask and answer questions here.
@@ -0,0 +1,35 @@
name: Feature request
description: Request a new feature (NOT A PLUGIN)
title: "[Request] <title>"
labels: [feature request]
body:
- type: checkboxes
id: low-effort-checks
attributes:
label: Please confirm
description: Issues without all checks may be ignored/closed.
options:
- label: I have searched existing issues
- label: This issue is not a duplicate of an existing one
- label: This is not a request for a plugin
- type: textarea
attributes:
label: Feature Request Description
description: A clear and concise description of what the new feature.
placeholder: |
Decky plugins should be sortable in the quick access menu
validations:
required: true
- type: textarea
attributes:
label: Further Description
description: A further explanation of the feature. If appropriate, include screenshots or videos.
placeholder: |
This would help make the UI clearer and easier to use as there is less clutter in the QAM.
It would also make it faster to access plugins that are used more.
This could be implemented by adding ...
validations:
required: false
+47 -6
View File
@@ -31,7 +31,7 @@ permissions:
jobs:
build:
name: Build PluginLoader
runs-on: ubuntu-latest
runs-on: ubuntu-20.04
steps:
- name: Print input
@@ -69,7 +69,7 @@ jobs:
run: pnpm run build
- name: Build Python Backend 🛠️
run: pyinstaller --noconfirm --onefile --name "PluginLoader" --add-data ./backend/static:/static --add-data ./backend/legacy:/legacy ./backend/*.py
run: pyinstaller --noconfirm --onefile --name "PluginLoader" --add-data ./backend/static:/static --add-data ./backend/legacy:/legacy --add-data ./plugin:/plugin ./backend/*.py
- name: Upload package artifact ⬆️
if: ${{ !env.ACT }}
@@ -84,6 +84,49 @@ jobs:
with:
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:
name: Release stable version of the package
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release == 'release' }}
@@ -130,9 +173,7 @@ jobs:
OUT=$(semver bump ${{github.event.inputs.bump}} "$OUT")
printf "OUT: ${OUT}\n"
else
printf "no type selected, defaulting to patch.\n"
OUT=$(semver bump patch "$OUT")
printf "OUT: ${OUT}\n"
printf "no type selected, not bumping for release.\n"
fi
elif [[ ! "$VERSION" =~ "-pre" ]]; then
printf "previous tag is a release, bumping by selected type.\n"
@@ -159,7 +200,7 @@ jobs:
uses: softprops/action-gh-release@v1
if: ${{ github.event_name == 'workflow_dispatch' && !env.ACT }}
with:
name: Prerelease ${{ steps.ready_tag.outputs.tag_name }}
name: Release ${{ steps.ready_tag.outputs.tag_name }}
tag_name: ${{ steps.ready_tag.outputs.tag_name }}
files: ./dist/PluginLoader
prerelease: false
+55
View File
@@ -0,0 +1,55 @@
name: Push Updated Plugin Stub to Template
on: push
jobs:
copy-stub:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@8230315d06ad95c617244d2f265d237a1682d445
with:
ref: ${{ github.sha }}
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Checkout
uses: actions/checkout@v2
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v35.6.3
with:
separator: ","
files: |
plugin/*
- name: Is stub changed
id: changed-stub
run: |
STUB_CHANGED="false"
PATHS=(plugin plugin/decky_plugin.pyi)
SHA=${{ github.sha }}
SHA_PREV=HEAD^
FILES=$(git diff $SHA_PREV..$SHA --name-only -- ${PATHS[@]} | jq -Rsc 'split("\n")[:-1] | join (",")')
if [[ "$FILES" == *"plugin/decky_plugin.pyi"* ]]; then
$STUB_CHANGED="true"
echo "Stub has changed, pushing updated stub"
else
echo "Stub has not changed, exiting."
echo "has_changed=$STUB_CHANGED" >> $GITHUB_OUTPUT
exit 0
fi
echo "has_changed=$STUB_CHANGED" >> $GITHUB_OUTPUT
- name: Push updated stub
if: steps.changed-stub.outputs.has_changed == true
uses: dmnemec/copy_file_to_another_repo_action@bbebd3da22e4a37d04dca5f782edd5201cb97083
env:
API_TOKEN_GITHUB: ${{ secrets.GITHUB_TOKEN }}
with:
source_file: 'plugin/decky_plugin.pyi'
destination_repo: 'SteamDeckHomebrew/decky-plugin-template'
user_email: '11465594+TrainDoctor@users.noreply.github.com'
user_name: 'TrainDoctor'
commit_message: 'Updated template with latest plugin stub changes'
+17
View File
@@ -0,0 +1,17 @@
name: Lint
on:
push:
jobs:
lint:
name: Run linters
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2 # Check out the repository first.
- name: Run prettier (JavaScript & TypeScript)
run: |
pushd frontend
npm install
npm run lint
+1 -1
View File
@@ -4,4 +4,4 @@
"deckpass" : "ssap",
"deckkey" : "-i ${env:HOME}/.ssh/id_rsa",
"deckdir" : "/home/deck"
}
}
+21 -19
View File
@@ -1,7 +1,9 @@
<h1 align="center">
<a name="logo" href="https://deckbrew.xyz/"><img src="https://deckbrew.xyz/logo.png" alt="Deckbrew logo" width="200"></a>
<a name="logo" href="https://deckbrew.xyz/"><img src="https://deckbrew.xyz/static/icon-45ca1f5aea376a9ad37e92db906f283e.png" alt="Deckbrew logo" width="200"></a>
<br>
Decky Loader
<br>
<a name="download button" href="https://github.com/SteamDeckHomebrew/decky-installer/releases/latest/download/decky_installer.desktop"><img src="./docs/images/download_button.svg" alt="Download decky" width="350px" style="padding-top: 15px;"></a>
</h1>
<p align="center">
@@ -9,7 +11,7 @@
<a href="https://github.com/SteamDeckHomebrew/decky-loader/stargazers"><img src="https://img.shields.io/github/stars/SteamDeckHomebrew/decky-loader" /></a>
<a href="https://github.com/SteamDeckHomebrew/decky-loader/commits/main"><img src="https://img.shields.io/github/last-commit/SteamDeckHomebrew/decky-loader.svg" /></a>
<a href="https://github.com/SteamDeckHomebrew/decky-loader/blob/main/LICENSE"><img src="https://img.shields.io/github/license/SteamDeckHomebrew/decky-loader" /></a>
<a href="https://discord.gg/ZU74G2NJzk"><img src="https://img.shields.io/discord/960281551428522045?color=%235865F2&label=discord" /></a>
<a href="https://deckbrew.xyz/discord"><img src="https://img.shields.io/discord/960281551428522045?color=%235865F2&label=discord" /></a>
<br>
<br>
<img src="https://media.discordapp.net/attachments/966017112244125756/1012466063893610506/main.jpg" alt="Decky screenshot" width="80%">
@@ -31,41 +33,40 @@ For more information about Decky Loader as well as documentation and development
### 🤔 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.
- If you are using any software that uses port 1337 or 8080, please change its port to something else or uninstall it.
- Sometimes Decky will disappear on SteamOS updates. This can easily be fixed by just re-running the installer and installing the stable branch again. If this doesn't work, try installing the prerelease instead. If that doesn't work, then [check the existing issues](https://github.com/SteamDeckHomebrew/decky-loader/issues) and if there isn't one then you can [file a new issue](https://github.com/SteamDeckHomebrew/decky-loader/issues/new?assignees=&labels=bug&template=bug_report.yml&title=%5BBUG%5D+%3Ctitle%3E).
## 💾 Installation
1. Press the <img src="./docs/images/light/steam.svg#gh-dark-mode-only" height=16><img src="./docs/images/dark/steam.svg#gh-light-mode-only" height=16> button and open the Settings menu.
1. Navigate to the System menu and scroll to the System Settings. Toggle "Enable Developer Mode" so it is enabled.
1. Navigate to the Developer menu and scroll to Miscellaneous. Toggle "CEF Remote Debugging" so it is enabled.
1. Select "Restart Now" to apply your changes.
- This installation can be done without an admin/sudo password set.
1. Prepare a mouse and keyboard if possible.
- Keyboards and mice can be connected to the Steam Deck via USB-C or Bluetooth.
- Many Bluetooth keyboard and mouse apps are available for iOS and Android.
- Many Bluetooth keyboard and mouse apps are available for iOS and Android. (KDE connect is preinstalled on the steam deck)
- The Steam Link app is available on [Windows](https://media.steampowered.com/steamlink/windows/latest/SteamLink.zip), [macOS](https://apps.apple.com/us/app/steam-link/id1246969117), and [Linux](https://flathub.org/apps/details/com.valvesoftware.SteamLink). It works well as a remote desktop substitute.
- If you have no other options, use the right trackpad as a mouse and press <img src="./docs/images/light/steam.svg#gh-dark-mode-only" height=16><img src="./docs/images/dark/steam.svg#gh-light-mode-only" height=16>+<img src="./docs/images/light/x.svg#gh-dark-mode-only" height=16><img src="./docs/images/dark/x.svg#gh-light-mode-only" height=16> to open the on-screen keyboard as needed.
1. Press the <img src="./docs/images/light/steam.svg#gh-dark-mode-only" height=16><img src="./docs/images/dark/steam.svg#gh-light-mode-only" height=16> button and open the Power menu.
1. Select "Switch to Desktop".
1. Open the Konsole app and enter the command `passwd`. You can skip this step if you have already created a sudo password using this command. ([YouTube Guide](https://www.youtube.com/watch?v=1vOMYGj22rQ))
1. You will be prompted to create a password. Your text will not be visible. After you press enter, you will need to type your password again to confirm.
1. Choose the version of Decky Loader you want to install and paste the following command into the Konsole app.
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. Drag the file onto your desktop and double click it to run it.
1. Either type your admin password or allow Decky to temporarily set your admin password to `Decky!` (this password will be removed after the installer finishes)
1. Choose the version of Decky Loader you want to install.
- **Latest Release**
Intended for most users. This is the latest stable version of Decky Loader.
`curl -L https://github.com/SteamDeckHomebrew/decky-loader/raw/main/dist/install_release.sh | sh`
- **Latest Pre-Release**
Intended for plugin developers. Pre-releases are unlikely to be fully stable but contain the latest changes. For more information on plugin development, please consult [the wiki page](https://deckbrew.xyz/en/loader-dev/development).
`curl -L https://github.com/SteamDeckHomebrew/decky-loader/raw/main/dist/install_prerelease.sh | sh`
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).
1. Open the Return to Gaming Mode shortcut on your desktop.
- There is also a fast install for those who can use Konsole. Run `curl -L https://github.com/SteamDeckHomebrew/decky-installer/releases/latest/download/install_release.sh | sh` and type your password when prompted.
### 👋 Uninstallation
We are sorry to see you go! If you are considering uninstalling because you are having issues, please consider [opening an issue](https://github.com/SteamDeckHomebrew/decky-loader/issues) or [joining our Discord](https://discord.gg/ZU74G2NJzk) so we can help you and other users.
We are sorry to see you go! If you are considering uninstalling because you are having issues, please consider [opening an issue](https://github.com/SteamDeckHomebrew/decky-loader/issues) or [joining our Discord](https://deckbrew.xyz/discord) so we can help you and other users.
1. Press the <img src="./docs/images/light/steam.svg#gh-dark-mode-only" height=16><img src="./docs/images/dark/steam.svg#gh-light-mode-only" height=16> button and open the Power menu.
1. Select "Switch to Desktop".
1. Open the Konsole app and run `curl -L https://github.com/SteamDeckHomebrew/decky-loader/raw/main/dist/uninstall.sh | sh`.
1. Run the installer file again, and select `uninstall decky loader`.
- There is also a fast uninstall for those who can use Konsole. Run `curl -L https://github.com/SteamDeckHomebrew/decky-installer/releases/latest/download/uninstall.sh | sh` and type your password when prompted.
## 🚀 Getting Started
@@ -82,15 +83,16 @@ Now that you have Decky Loader installed, you can start using plugins. Each plug
### 🛠️ Plugin Development
There is no complete plugin development documentation yet. However a good starting point is the [plugin template repository](https://github.com/SteamDeckHomebrew/decky-plugin-template). Consider [joining our Discord](https://discord.gg/ZU74G2NJzk) if you have any questions.
There is no complete plugin development documentation yet. However a good starting point is the [plugin template repository](https://github.com/SteamDeckHomebrew/decky-plugin-template). Consider [joining our Discord](https://deckbrew.xyz/discord) if you have any questions.
### 🤝 Contributing
Please consult [the wiki page regarding development](https://deckbrew.xyz/en/loader-dev/development) for more information on installing development versions of Decky Loader. You can also install the Steam Deck UI on a Windows or Linux computer for testing by following [this YouTube guide](https://youtu.be/1IAbZte8e7E?t=112).
Please consult [the wiki page regarding development](https://wiki.deckbrew.xyz/en/loader-dev/development) for more information on installing development versions of Decky Loader. You can also install the Steam Deck UI on a Windows or Linux computer for testing by following [this YouTube guide](https://youtu.be/1IAbZte8e7E?t=112).
1. Clone the repository using the latest commit to main before starting your PR.
1. In your clone of the repository, run these commands.
```bash
cd frontend
pnpm i
pnpm run build
```
+2 -2
View File
@@ -26,10 +26,10 @@ cd ..
if [[ "$type" == "release" ]]; then
printf "release!\n"
act workflow_dispatch -e act/release.json --artifact-server-path act/artifacts
act workflow_dispatch -e act/release.json --artifact-server-path act/artifacts --container-architecture linux/amd64
elif [[ "$type" == "prerelease" ]]; then
printf "prerelease!\n"
act workflow_dispatch -e act/prerelease.json --artifact-server-path act/artifacts
act workflow_dispatch -e act/prerelease.json --artifact-server-path act/artifacts --container-architecture linux/amd64
else
printf "Release type unspecified/badly specified.\n"
printf "Options: 'release' or 'prerelease'\n"
+43 -22
View File
@@ -1,5 +1,7 @@
# Full imports
import json
# import pprint
# from pprint import pformat
# Partial imports
from aiohttp import ClientSession, web
@@ -10,12 +12,12 @@ from io import BytesIO
from logging import getLogger
from os import R_OK, W_OK, path, rename, listdir, access, mkdir
from shutil import rmtree
from subprocess import call
from time import time
from zipfile import ZipFile
from localplatform import chown, chmod
# Local modules
from helpers import get_ssl_context, get_user, get_user_group, download_remote_binary_to_path
from helpers import get_ssl_context, download_remote_binary_to_path
from injector import get_gamepadui_tab
logger = getLogger("Browser")
@@ -28,10 +30,11 @@ class PluginInstallContext:
self.hash = hash
class PluginBrowser:
def __init__(self, plugin_path, plugins, loader) -> None:
def __init__(self, plugin_path, plugins, loader, settings) -> None:
self.plugin_path = plugin_path
self.plugins = plugins
self.loader = loader
self.settings = settings
self.install_requests = {}
def _unzip_to_plugin_dir(self, zip, name, hash):
@@ -40,11 +43,10 @@ class PluginBrowser:
return False
zip_file = ZipFile(zip)
zip_file.extractall(self.plugin_path)
plugin_dir = self.find_plugin_folder(name)
code_chown = call(["chown", "-R", get_user()+":"+get_user_group(), plugin_dir])
code_chmod = call(["chmod", "-R", "555", plugin_dir])
if code_chown != 0 or code_chmod != 0:
logger.error(f"chown/chmod exited with a non-zero exit code (chown: {code_chown}, chmod: {code_chmod})")
plugin_dir = path.join(self.plugin_path, self.find_plugin_folder(name))
if not chown(plugin_dir) or not chmod(plugin_dir, 555):
logger.error(f"chown/chmod exited with a non-zero exit code")
return False
return True
@@ -55,18 +57,18 @@ class PluginBrowser:
pluginBinPath = path.join(pluginBasePath, 'bin')
if access(packageJsonPath, R_OK):
with open(packageJsonPath, 'r') as f:
with open(packageJsonPath, "r", encoding="utf-8") as f:
packageJson = json.load(f)
if "remote_binary" in packageJson and len(packageJson["remote_binary"]) > 0:
# create bin directory if needed.
rc=call(["chmod", "-R", "777", pluginBasePath])
chmod(pluginBasePath, 777)
if access(pluginBasePath, W_OK):
if not path.exists(pluginBinPath):
mkdir(pluginBinPath)
if not access(pluginBinPath, W_OK):
rc=call(["chmod", "-R", "777", pluginBinPath])
chmod(pluginBinPath, 777)
rv = True
for remoteBinary in packageJson["remote_binary"]:
@@ -78,8 +80,8 @@ class PluginBrowser:
rv = False
raise Exception(f"Error Downloading Remote Binary {binName}@{binURL} with hash {binHash} to {path.join(pluginBinPath, binName)}")
code_chown = call(["chown", "-R", get_user()+":"+get_user_group(), self.plugin_path])
rc=call(["chmod", "-R", "555", pluginBasePath])
chown(self.plugin_path)
chmod(pluginBasePath, 555)
else:
rv = True
logger.debug(f"No Remote Binaries to Download")
@@ -90,14 +92,15 @@ class PluginBrowser:
return rv
"""Return the filename (only) for the specified plugin"""
def find_plugin_folder(self, name):
for folder in listdir(self.plugin_path):
try:
with open(path.join(self.plugin_path, folder, 'plugin.json'), 'r') as f:
with open(path.join(self.plugin_path, folder, 'plugin.json'), "r", encoding="utf-8") as f:
plugin = json.load(f)
if plugin['name'] == name:
return str(path.join(self.plugin_path, folder))
return folder
except:
logger.debug(f"skipping {folder}")
@@ -105,20 +108,32 @@ class PluginBrowser:
if self.loader.watcher:
self.loader.watcher.disabled = True
tab = await get_gamepadui_tab()
plugin_dir = path.join(self.plugin_path, self.find_plugin_folder(name))
try:
logger.info("uninstalling " + name)
logger.info(" at dir " + self.find_plugin_folder(name))
logger.debug("unloading %s" % str(name))
await tab.evaluate_js(f"DeckyPluginLoader.unloadPlugin('{name}')")
logger.info(" at dir " + plugin_dir)
logger.debug("calling frontend unload for %s" % str(name))
res = await tab.evaluate_js(f"DeckyPluginLoader.unloadPlugin('{name}')")
logger.debug("result of unload from UI: %s", res)
# plugins_snapshot = self.plugins.copy()
# snapshot_string = pformat(plugins_snapshot)
# logger.debug("current plugins: %s", snapshot_string)
if self.plugins[name]:
logger.debug("Plugin %s was found", name)
self.plugins[name].stop()
logger.debug("Plugin %s was stopped", name)
del self.plugins[name]
logger.debug("Plugin %s was removed from the dictionary", name)
current_plugin_order = self.settings.getSetting("pluginOrder")
current_plugin_order.remove(name)
self.settings.setSetting("pluginOrder", current_plugin_order)
logger.debug("Plugin %s was removed from the pluginOrder setting", name)
logger.debug("removing files %s" % str(name))
rmtree(self.find_plugin_folder(name))
rmtree(plugin_dir)
except FileNotFoundError:
logger.warning(f"Plugin {name} not installed, skipping uninstallation")
except Exception as e:
logger.error(f"Plugin {name} in {self.find_plugin_folder(name)} was not uninstalled")
logger.error(f"Plugin {name} in {plugin_dir} was not uninstalled")
logger.error(f"Error at %s", exc_info=e)
if self.loader.watcher:
self.loader.watcher.disabled = False
@@ -151,7 +166,8 @@ class PluginBrowser:
logger.debug("Unzipping...")
ret = self._unzip_to_plugin_dir(res_zip, name, hash)
if ret:
plugin_dir = self.find_plugin_folder(name)
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})")
@@ -159,7 +175,12 @@ class PluginBrowser:
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)
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:
logger.fatal(f"Failed Downloading Remote Binaries")
else:
+6
View File
@@ -0,0 +1,6 @@
from enum import Enum
class UserType(Enum):
HOST_USER = 1
EFFECTIVE_USER = 2
ROOT = 3
+101 -65
View File
@@ -1,27 +1,28 @@
import re
import ssl
import subprocess
import uuid
import os
from subprocess import check_output
from time import sleep
import sys
import subprocess
from hashlib import sha256
from io import BytesIO
import certifi
from aiohttp.web import Response, middleware
from aiohttp import ClientSession
import localplatform
from customtypes import UserType
from logging import getLogger
REMOTE_DEBUGGER_UNIT = "steam-web-debug-portforward.service"
# global vars
csrf_token = str(uuid.uuid4())
ssl_ctx = ssl.create_default_context(cafile=certifi.where())
user = None
group = None
assets_regex = re.compile("^/plugins/.*/assets/.*")
frontend_regex = re.compile("^/frontend/.*")
logger = getLogger("Main")
def get_ssl_context():
return ssl_ctx
@@ -35,57 +36,41 @@ async def csrf_middleware(request, handler):
return await handler(request)
return Response(text='Forbidden', status='403')
# Get the user by checking for the first logged in user. As this is run
# by systemd at startup the process is likely to start before the user
# logs in, so we will wait here until they are available. Note that
# other methods such as getenv wont work as there was no $SUDO_USER to
# start the systemd service.
def set_user():
global user
cmd = "who | awk '{print $1}' | sort | head -1"
while user == None:
name = check_output(cmd, shell=True).decode().strip()
if name not in [None, '']:
user = name
sleep(0.1)
# Get the global user. get_user must be called first.
def get_user() -> str:
global user
if user == None:
raise ValueError("helpers.get_user method called before user variable was set. Run helpers.set_user first.")
return user
# Set the global user group. get_user must be called first
def set_user_group() -> str:
global group
global user
if user == None:
raise ValueError("helpers.set_user_dir method called before user variable was set. Run helpers.set_user first.")
if group == None:
group = check_output(["id", "-g", "-n", user]).decode().strip()
# Get the group of the global user. set_user_group must be called first.
def get_user_group() -> str:
global group
if group == None:
raise ValueError("helpers.get_user_group method called before group variable was set. Run helpers.set_user_group first.")
return group
# Get the default home path unless a user is specified
def get_home_path(username = None) -> str:
if username == None:
raise ValueError("Username not defined, no home path can be found.")
else:
return str("/home/"+username)
# Get the default homebrew path unless a user is specified
# Get the default homebrew path unless a home_path is specified
def get_homebrew_path(home_path = None) -> str:
if home_path == None:
raise ValueError("Home path not defined, homebrew dir cannot be determined.")
else:
return str(home_path+"/homebrew")
# return str(home_path+"/homebrew")
return os.path.join(home_path if home_path != None else localplatform.get_home_path(), "homebrew")
# Recursively create path and chown as user
def mkdir_as_user(path):
path = os.path.realpath(path)
os.makedirs(path, exist_ok=True)
localplatform.chown(path)
# Fetches the version of loader
def get_loader_version() -> str:
try:
with open(os.path.join(os.getcwd(), ".loader.version"), "r", encoding="utf-8") as version_file:
return version_file.readline().strip()
except Exception as e:
logger.warn(f"Failed to execute get_loader_version(): {str(e)}")
return "unknown"
# returns the appropriate system python paths
def get_system_pythonpaths() -> list[str]:
extra_args = {}
if localplatform.ON_LINUX:
# run as normal normal user to also include user python paths
extra_args["user"] = localplatform.localplatform._get_user_id()
extra_args["env"] = {}
try:
proc = subprocess.run(["python3" if localplatform.ON_LINUX else "python", "-c", "import sys; print('\\n'.join(x for x in sys.path if x))"],
capture_output=True, **extra_args)
return [x.strip() for x in proc.stdout.decode().strip().split("\n")]
except Exception as e:
logger.warn(f"Failed to execute get_system_pythonpaths(): {str(e)}")
return []
# Download Remote Binaries to local Plugin
async def download_remote_binary_to_path(url, binHash, path) -> bool:
@@ -111,16 +96,67 @@ async def download_remote_binary_to_path(url, binHash, path) -> bool:
return rv
# 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:
res = subprocess.run(["systemctl", "is-active", unit_name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return res.returncode == 0
return await localplatform.service_active(unit_name)
async def stop_systemd_unit(unit_name: str) -> subprocess.CompletedProcess:
cmd = ["systemctl", "stop", unit_name]
async def stop_systemd_unit(unit_name: str) -> bool:
return await localplatform.service_stop(unit_name)
return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
async def start_systemd_unit(unit_name: str) -> subprocess.CompletedProcess:
cmd = ["systemctl", "start", unit_name]
return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
async def start_systemd_unit(unit_name: str) -> bool:
return await localplatform.service_start(unit_name)
+16 -2
View File
@@ -111,7 +111,7 @@ class Tab:
"method": "Page.disable",
}, False)
async def refresh(self):
async def refresh(self, manage_socket=True):
try:
if manage_socket:
await self.open_websocket()
@@ -394,9 +394,15 @@ async def get_tab_lambda(test) -> Tab:
raise ValueError(f"Tab not found by lambda")
return tab
SHARED_CTX_NAMES = ["SharedJSContext", "Steam Shared Context presented by Valve™", "Steam", "SP"]
DO_NOT_CLOSE_URL = "Valve Steam Gamepad/default" # Steam Big Picture Mode tab
def tab_is_gamepadui(t: Tab) -> bool:
return "https://steamloopback.host/routes/" in t.url and t.title in SHARED_CTX_NAMES
async def get_gamepadui_tab() -> Tab:
tabs = await get_tabs()
tab = next((i for i in tabs if ("https://steamloopback.host/routes/" in i.url and (i.title == "Steam" or i.title == "SP"))), None)
tab = next((i for i in tabs if tab_is_gamepadui(i)), None)
if not tab:
raise ValueError(f"GamepadUI Tab not found")
return tab
@@ -405,3 +411,11 @@ async def inject_to_tab(tab_name, js, run_async=False):
tab = await get_tab(tab_name)
return await tab.evaluate_js(js, run_async)
async def close_old_tabs():
tabs = await get_tabs()
for t in tabs:
if not t.title or (t.title not in SHARED_CTX_NAMES and DO_NOT_CLOSE_URL not in t.url):
logger.debug("Closing tab: " + getattr(t, "title", "Untitled"))
await t.close()
await sleep(0.5)
+5 -11
View File
@@ -6,19 +6,13 @@ from pathlib import Path
from traceback import print_exc
from aiohttp import web
from genericpath import exists
from os.path import exists
from watchdog.events import RegexMatchingEventHandler
from watchdog.utils import UnsupportedLibc
try:
from watchdog.observers.inotify import InotifyObserver as Observer
except UnsupportedLibc:
from watchdog.observers.fsevents import FSEventsObserver as Observer
from watchdog.observers import Observer
from injector import get_tab, get_gamepadui_tab
from plugin import PluginWrapper
class FileChangeHandler(RegexMatchingEventHandler):
def __init__(self, queue, plugin_path) -> None:
super().__init__(regexes=[r'^.*?dist\/index\.js$', r'^.*?main\.py$'])
@@ -118,7 +112,7 @@ class Loader:
def handle_frontend_bundle(self, request):
plugin = self.plugins[request.match_info["plugin_name"]]
with open(path.join(self.plugin_path, plugin.plugin_directory, "dist/index.js"), 'r') as bundle:
with open(path.join(self.plugin_path, plugin.plugin_directory, "dist/index.js"), "r", encoding="utf-8") as bundle:
return web.Response(text=bundle.read(), content_type="application/javascript")
def import_plugin(self, file, plugin_directory, refresh=False, batch=False):
@@ -186,7 +180,7 @@ class Loader:
"""
async def load_plugin_main_view(self, request):
plugin = self.plugins[request.match_info["name"]]
with open(path.join(self.plugin_path, plugin.plugin_directory, plugin.main_view_html), 'r') as template:
with open(path.join(self.plugin_path, plugin.plugin_directory, plugin.main_view_html), "r", encoding="utf-8") as template:
template_data = template.read()
ret = f"""
<script src="/legacy/library.js"></script>
@@ -202,7 +196,7 @@ class Loader:
self.logger.info(path)
ret = ""
file_path = path.join(self.plugin_path, plugin.plugin_directory, route_path)
with open(file_path, 'r') as resource_data:
with open(file_path, "r", encoding="utf-8") as resource_data:
ret = resource_data.read()
return web.Response(text=ret)
+11
View File
@@ -0,0 +1,11 @@
import platform
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
+138
View File
@@ -0,0 +1,138 @@
import os, pwd, grp, sys
from subprocess import call, run, DEVNULL, PIPE, STDOUT
from customtypes import UserType
# 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
# 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()
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
+38
View File
@@ -0,0 +1,38 @@
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()
+132
View File
@@ -0,0 +1,132 @@
import asyncio, time, random
from localplatform import ON_WINDOWS
BUFFER_LIMIT = 2 ** 20 # 1 MiB
class UnixSocket:
def __init__(self, on_new_message):
'''
on_new_message takes 1 string argument.
It's return value gets used, if not None, to write data to the socket.
Method should be async
'''
self.socket_addr = f"/tmp/plugin_socket_{time.time()}"
self.on_new_message = on_new_message
self.socket = None
self.reader = None
self.writer = None
async def setup_server(self):
self.socket = await asyncio.start_unix_server(self._listen_for_method_call, path=self.socket_addr, limit=BUFFER_LIMIT)
async def _open_socket_if_not_exists(self):
if not self.reader:
retries = 0
while retries < 10:
try:
self.reader, self.writer = await asyncio.open_unix_connection(self.socket_addr, limit=BUFFER_LIMIT)
return True
except:
await asyncio.sleep(2)
retries += 1
return False
else:
return True
async def get_socket_connection(self):
if not await self._open_socket_if_not_exists():
return None, None
return self.reader, self.writer
async def close_socket_connection(self):
if self.writer != None:
self.writer.close()
self.reader = None
async def read_single_line(self) -> str|None:
reader, writer = await self.get_socket_connection()
if self.reader == None:
return None
return await self._read_single_line(reader)
async def write_single_line(self, message : str):
reader, writer = await self.get_socket_connection()
if self.writer == None:
return;
await self._write_single_line(writer, message)
async def _read_single_line(self, reader) -> str:
line = bytearray()
while True:
try:
line.extend(await reader.readuntil())
except asyncio.LimitOverrunError:
line.extend(await reader.read(reader._limit))
continue
except asyncio.IncompleteReadError as err:
line.extend(err.partial)
break
else:
break
return line.decode("utf-8")
async def _write_single_line(self, writer, message : str):
if not message.endswith("\n"):
message += "\n"
writer.write(message.encode("utf-8"))
await writer.drain()
async def _listen_for_method_call(self, reader, writer):
while True:
line = await self._read_single_line(reader)
try:
res = await self.on_new_message(line)
except Exception as e:
return
if res != None:
await self._write_single_line(writer, res)
class PortSocket (UnixSocket):
def __init__(self, on_new_message):
'''
on_new_message takes 1 string argument.
It's return value gets used, if not None, to write data to the socket.
Method should be async
'''
super().__init__(on_new_message)
self.host = "127.0.0.1"
self.port = random.sample(range(40000, 60000), 1)[0]
async def setup_server(self):
self.socket = await asyncio.start_server(self._listen_for_method_call, host=self.host, port=self.port, limit=BUFFER_LIMIT)
async def _open_socket_if_not_exists(self):
if not self.reader:
retries = 0
while retries < 10:
try:
self.reader, self.writer = await asyncio.open_connection(host=self.host, port=self.port, limit=BUFFER_LIMIT)
return True
except:
await asyncio.sleep(2)
retries += 1
return False
else:
return True
if ON_WINDOWS:
class LocalSocket (PortSocket):
pass
else:
class LocalSocket (UnixSocket):
pass
+44 -38
View File
@@ -1,14 +1,15 @@
# Change PyInstaller files permissions
import sys
from subprocess import call
from localplatform import chmod, chown, service_stop, service_start, ON_WINDOWS
if hasattr(sys, '_MEIPASS'):
call(['chmod', '-R', '755', sys._MEIPASS])
chmod(sys._MEIPASS, 755)
# Full imports
from asyncio import new_event_loop, set_event_loop, sleep
from json import dumps, loads
from logging import DEBUG, INFO, basicConfig, getLogger
from os import getenv, chmod, path
from os import getenv, path
from traceback import format_exc
import multiprocessing
import aiohttp_cors
# Partial imports
@@ -19,27 +20,18 @@ from aiohttp_jinja2 import setup as jinja_setup
# local modules
from browser import PluginBrowser
from helpers import (REMOTE_DEBUGGER_UNIT, csrf_middleware, get_csrf_token,
get_home_path, get_homebrew_path, get_user,
get_user_group, set_user, set_user_group,
stop_systemd_unit, start_systemd_unit)
from injector import get_gamepadui_tab, Tab, get_tabs
get_homebrew_path, mkdir_as_user, get_system_pythonpaths)
from injector import get_gamepadui_tab, Tab, get_tabs, close_old_tabs
from loader import Loader
from settings import SettingsManager
from updater import Updater
from utilities import Utilities
from customtypes import UserType
# Ensure USER and GROUP vars are set first.
# TODO: This isn't the best way to do this but supports the current
# implementation. All the config load and environment setting eventually be
# moved into init or a config/loader method.
set_user()
set_user_group()
USER = get_user()
GROUP = get_user_group()
HOME_PATH = "/home/"+USER
HOMEBREW_PATH = HOME_PATH+"/homebrew"
HOMEBREW_PATH = get_homebrew_path()
CONFIG = {
"plugin_path": getenv("PLUGIN_PATH", HOMEBREW_PATH+"/plugins"),
"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")),
@@ -56,11 +48,15 @@ basicConfig(
logger = getLogger("Main")
async def chown_plugin_dir():
code_chown = call(["chown", "-R", USER+":"+GROUP, CONFIG["plugin_path"]])
code_chmod = call(["chmod", "-R", "555", CONFIG["plugin_path"]])
if code_chown != 0 or code_chmod != 0:
logger.error(f"chown/chmod exited with a non-zero exit code (chown: {code_chown}, chmod: {code_chmod})")
def chown_plugin_dir():
if not path.exists(CONFIG["plugin_path"]): # For safety, create the folder before attempting to do anything with it
mkdir_as_user(CONFIG["plugin_path"])
if not chown(CONFIG["plugin_path"], UserType.HOST_USER) or not chmod(CONFIG["plugin_path"], 555):
logger.error(f"chown/chmod exited with a non-zero exit code")
if CONFIG["chown_plugin_path"] == True:
chown_plugin_dir()
class PluginManager:
def __init__(self, loop) -> None:
@@ -75,8 +71,8 @@ class PluginManager:
)
})
self.plugin_loader = Loader(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.plugin_browser = PluginBrowser(CONFIG["plugin_path"], self.plugin_loader.plugins, self.plugin_loader, self.settings)
self.utilities = Utilities(self)
self.updater = Updater(self)
@@ -84,11 +80,9 @@ class PluginManager:
async def startup(_):
if self.settings.getSetting("cef_forward", False):
self.loop.create_task(start_systemd_unit(REMOTE_DEBUGGER_UNIT))
self.loop.create_task(service_start(REMOTE_DEBUGGER_UNIT))
else:
self.loop.create_task(stop_systemd_unit(REMOTE_DEBUGGER_UNIT))
if CONFIG["chown_plugin_path"] == True:
chown_plugin_dir()
self.loop.create_task(service_stop(REMOTE_DEBUGGER_UNIT))
self.loop.create_task(self.loader_reinjector())
self.loop.create_task(self.load_plugins())
@@ -115,6 +109,9 @@ class PluginManager:
logger.debug("Loading plugins")
self.plugin_loader.import_plugins()
# 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):
while True:
@@ -124,7 +121,7 @@ class PluginManager:
while not tab:
try:
tab = await get_gamepadui_tab()
except client_exceptions.ClientConnectorError or client_exceptions.ServerDisconnectedError:
except (client_exceptions.ClientConnectorError, client_exceptions.ServerDisconnectedError):
if not dc:
logger.debug("Couldn't connect to debugger, waiting...")
dc = True
@@ -168,15 +165,10 @@ class PluginManager:
async def inject_javascript(self, tab: Tab, first=False, request=None):
logger.info("Loading Decky frontend!")
try:
# if first:
# if await tab.has_global_var("deckyHasLoaded", False):
# tabs = await get_tabs()
# for t in tabs:
# if t.title != "Steam" and t.title != "SP":
# logger.debug("Closing tab: " + getattr(t, "title", "Untitled"))
# await t.close()
# await sleep(0.5)
await tab.evaluate_js("try{if (window.deckyHasLoaded){setTimeout(() => SteamClient.User.StartRestart(), 100)}else{window.deckyHasLoaded = true;(async()=>{try{while(!window.SP_REACT){await new Promise(r => setTimeout(r, 10))};await import('http://localhost:1337/frontend/index.js')}catch(e){console.error(e)};})();}}catch(e){console.error(e)}", False, False, False)
if first:
if await tab.has_global_var("deckyHasLoaded", False):
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)
except:
logger.info("Failed to inject JavaScript into tab\n" + format_exc())
pass
@@ -185,6 +177,20 @@ class PluginManager:
return run_app(self.web_app, host=CONFIG["server_host"], port=CONFIG["server_port"], loop=self.loop, access_log=None)
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()
set_event_loop(loop)
PluginManager(loop).run()
+71 -86
View File
@@ -1,38 +1,35 @@
import multiprocessing
from asyncio import (Lock, get_event_loop, new_event_loop,
open_unix_connection, set_event_loop, sleep,
start_unix_server, IncompleteReadError, LimitOverrunError)
set_event_loop, sleep)
from concurrent.futures import ProcessPoolExecutor
from importlib.util import module_from_spec, spec_from_file_location
from json import dumps, load, loads
from logging import getLogger
from traceback import format_exc
from os import path, setgid, setuid
from os import path, environ
from signal import SIGINT, signal
from sys import exit
from sys import exit, path as syspath
from time import time
multiprocessing.set_start_method("fork")
BUFFER_LIMIT = 2 ** 20 # 1 MiB
from localsocket import LocalSocket
from localplatform import setgid, setuid, get_username, get_home_path
from customtypes import UserType
import helpers
class PluginWrapper:
def __init__(self, file, plugin_directory, plugin_path) -> None:
self.file = file
self.plugin_path = plugin_path
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.socket = LocalSocket(self._on_new_message)
self.version = None
json = load(open(path.join(plugin_path, plugin_directory, "plugin.json"), "r"))
json = load(open(path.join(plugin_path, plugin_directory, "plugin.json"), "r", encoding="utf-8"))
if path.isfile(path.join(plugin_path, plugin_directory, "package.json")):
package_json = load(open(path.join(plugin_path, plugin_directory, "package.json"), "r"))
package_json = load(open(path.join(plugin_path, plugin_directory, "package.json"), "r", encoding="utf-8"))
self.version = package_json["version"]
self.legacy = False
self.main_view_html = json["main_view_html"] if "main_view_html" in json else ""
self.tile_view_html = json["tile_view_html"] if "tile_view_html" in json else ""
@@ -56,16 +53,39 @@ class PluginWrapper:
set_event_loop(new_event_loop())
if self.passive:
return
setgid(0 if "root" in self.flags else 1000)
setuid(0 if "root" in self.flags else 1000)
setgid(UserType.ROOT if "root" in self.flags else UserType.HOST_USER)
setuid(UserType.ROOT if "root" in self.flags else UserType.HOST_USER)
# export a bunch of environment variables to help plugin developers
environ["HOME"] = get_home_path(UserType.ROOT if "root" in self.flags else UserType.HOST_USER)
environ["USER"] = "root" if "root" in self.flags else get_username()
environ["DECKY_VERSION"] = helpers.get_loader_version()
environ["DECKY_USER"] = get_username()
environ["DECKY_USER_HOME"] = helpers.get_home_path()
environ["DECKY_HOME"] = helpers.get_homebrew_path()
environ["DECKY_PLUGIN_SETTINGS_DIR"] = path.join(environ["DECKY_HOME"], "settings", self.plugin_directory)
helpers.mkdir_as_user(environ["DECKY_PLUGIN_SETTINGS_DIR"])
environ["DECKY_PLUGIN_RUNTIME_DIR"] = path.join(environ["DECKY_HOME"], "data", self.plugin_directory)
helpers.mkdir_as_user(environ["DECKY_PLUGIN_RUNTIME_DIR"])
environ["DECKY_PLUGIN_LOG_DIR"] = path.join(environ["DECKY_HOME"], "logs", self.plugin_directory)
helpers.mkdir_as_user(environ["DECKY_PLUGIN_LOG_DIR"])
environ["DECKY_PLUGIN_DIR"] = path.join(self.plugin_path, self.plugin_directory)
environ["DECKY_PLUGIN_NAME"] = self.name
environ["DECKY_PLUGIN_VERSION"] = self.version
environ["DECKY_PLUGIN_AUTHOR"] = self.author
# append the plugin's `py_modules` to the recognized python paths
syspath.append(path.join(environ["DECKY_PLUGIN_DIR"], "py_modules"))
spec = spec_from_file_location("_", self.file)
module = module_from_spec(spec)
spec.loader.exec_module(module)
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"):
get_event_loop().create_task(self.Plugin._main(self.Plugin))
get_event_loop().create_task(self._setup_socket())
get_event_loop().create_task(self.socket.setup_server())
get_event_loop().run_forever()
except:
self.log.error("Failed to start " + self.name + "!\n" + format_exc())
@@ -73,61 +93,36 @@ class PluginWrapper:
async def _unload(self):
try:
self.log.info("Attempting to unload " + self.name + "\n")
self.log.info("Attempting to unload with plugin " + self.name + "'s \"_unload\" function.\n")
if hasattr(self.Plugin, "_unload"):
await self.Plugin._unload(self.Plugin)
self.log.info("Unloaded " + self.name + "\n")
else:
self.log.info("Could not find \"_unload\" in " + self.name + "'s main.py" + "\n")
except:
self.log.error("Failed to unload " + self.name + "!\n" + format_exc())
exit(0)
async def _setup_socket(self):
self.socket = await start_unix_server(self._listen_for_method_call, path=self.socket_addr, limit=BUFFER_LIMIT)
async def _on_new_message(self, message : str) -> str|None:
data = loads(message)
async def _listen_for_method_call(self, reader, writer):
while True:
line = bytearray()
while True:
try:
line.extend(await reader.readuntil())
except LimitOverrunError:
line.extend(await reader.read(reader._limit))
continue
except IncompleteReadError as err:
line.extend(err.partial)
break
else:
break
data = loads(line.decode("utf-8"))
if "stop" in data:
await self._unload()
get_event_loop().stop()
while get_event_loop().is_running():
await sleep(0)
get_event_loop().close()
return
d = {"res": None, "success": True}
try:
d["res"] = await getattr(self.Plugin, data["method"])(self.Plugin, **data["args"])
except Exception as e:
d["res"] = str(e)
d["success"] = False
finally:
writer.write((dumps(d)+"\n").encode("utf-8"))
await writer.drain()
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()
raise Exception("Closing message listener")
async def _open_socket_if_not_exists(self):
if not self.reader:
retries = 0
while retries < 10:
try:
self.reader, self.writer = await open_unix_connection(self.socket_addr, limit=BUFFER_LIMIT)
return True
except:
await sleep(2)
retries += 1
return False
else:
return True
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:
return dumps(d, ensure_ascii=False)
def start(self):
if self.passive:
@@ -138,34 +133,24 @@ class PluginWrapper:
def stop(self):
if self.passive:
return
async def _(self):
if await self._open_socket_if_not_exists():
self.writer.write((dumps({"stop": True})+"\n").encode("utf-8"))
await self.writer.drain()
self.writer.close()
await self.socket.write_single_line(dumps({ "stop": True }, ensure_ascii=False))
await self.socket.close_socket_connection()
get_event_loop().create_task(_(self))
async def execute_method(self, method_name, kwargs):
if self.passive:
raise RuntimeError("This plugin is passive (aka does not implement main.py)")
async with self.method_call_lock:
if await self._open_socket_if_not_exists():
self.writer.write(
(dumps({"method": method_name, "args": kwargs})+"\n").encode("utf-8"))
await self.writer.drain()
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"))
reader, writer = await self.socket.get_socket_connection()
await self.socket.write_single_line(dumps({ "method": method_name, "args": kwargs }, ensure_ascii=False))
line = await self.socket.read_single_line()
if line != None:
res = loads(line)
if not res["success"]:
raise Exception(res["res"])
return res["res"]
return res["res"]
+27 -11
View File
@@ -1,42 +1,58 @@
import imp
from json import dump, load
from os import mkdir, path
from os import mkdir, path, listdir, rename
from localplatform import chown, folder_owner
from customtypes import UserType
from helpers import get_home_path, get_homebrew_path, get_user, set_user
from helpers import get_homebrew_path
class SettingsManager:
def __init__(self, name, settings_directory = None) -> None:
set_user()
USER = get_user()
wrong_dir = get_homebrew_path()
if settings_directory == None:
settings_directory = get_homebrew_path(get_home_path(USER))
settings_directory = path.join(wrong_dir, "settings")
self.path = path.join(settings_directory, name + ".json")
#Create the folder with the correct permission
if not path.exists(settings_directory):
mkdir(settings_directory)
chown(settings_directory)
#Copy all old settings file in the root directory to the correct folder
for file in listdir(wrong_dir):
if file.endswith(".json"):
rename(path.join(wrong_dir,file),
path.join(settings_directory, file))
self.path = path.join(settings_directory, name + ".json")
#If the owner of the settings directory is not the user, then set it as the user:
if folder_owner(settings_directory) != UserType.HOST_USER:
chown(settings_directory, UserType.HOST_USER, False)
self.settings = {}
try:
open(self.path, "x")
open(self.path, "x", encoding="utf-8")
except FileExistsError as e:
self.read()
pass
def read(self):
try:
with open(self.path, "r") as file:
with open(self.path, "r", encoding="utf-8") as file:
self.settings = load(file)
except Exception as e:
print(e)
pass
def commit(self):
with open(self.path, "w+") as file:
dump(self.settings, file, indent=4)
with open(self.path, "w+", encoding="utf-8") as file:
dump(self.settings, file, indent=4, ensure_ascii=False)
def getSetting(self, key, default):
def getSetting(self, key, default=None):
return self.settings.get(key, default)
def setSetting(self, key, value):
+55 -53
View File
@@ -6,7 +6,7 @@ from ensurepip import version
from json.decoder import JSONDecodeError
from logging import getLogger
from os import getcwd, path, remove
from subprocess import call
from localplatform import chmod, service_restart, ON_LINUX
from aiohttp import ClientSession, web
@@ -30,12 +30,7 @@ class Updater:
}
self.remoteVer = None
self.allRemoteVers = None
try:
logger.info(getcwd())
with open(path.join(getcwd(), ".loader.version"), 'r') as version_file:
self.localVer = version_file.readline().replace("\n", "")
except:
self.localVer = False
self.localVer = helpers.get_loader_version()
try:
self.currentBranch = self.get_branch(self.context.settings)
@@ -70,7 +65,7 @@ class Updater:
logger.debug("current branch: %i" % ver)
if ver == -1:
logger.info("Current branch is not set, determining branch from version...")
if self.localVer.startswith("v") and self.localVer.find("-pre"):
if self.localVer.startswith("v") and "-pre" in self.localVer:
logger.info("Current version determined to be pre-release")
return 1
else:
@@ -96,15 +91,12 @@ class Updater:
return str(url)
async def get_version(self):
if self.localVer:
return {
"current": self.localVer,
"remote": self.remoteVer,
"all": self.allRemoteVers,
"updatable": self.localVer != None
}
else:
return {"current": "unknown", "remote": self.remoteVer, "all": self.allRemoteVers, "updatable": False}
return {
"current": self.localVer,
"remote": self.remoteVer,
"all": self.allRemoteVers,
"updatable": self.localVer != "unknown"
}
async def check_for_updates(self):
logger.debug("checking for updates")
@@ -116,10 +108,10 @@ class Updater:
logger.debug("determining release type to find, branch is %i" % selectedBranch)
if selectedBranch == 0:
logger.debug("release type: release")
self.remoteVer = next(filter(lambda ver: ver["tag_name"].startswith("v") and not ver["prerelease"] and ver["tag_name"], remoteVersions), None)
self.remoteVer = next(filter(lambda ver: ver["tag_name"].startswith("v") and not ver["prerelease"] and not ver["tag_name"].find("-pre") > 0 and ver["tag_name"], remoteVersions), None)
elif selectedBranch == 1:
logger.debug("release type: pre-release")
self.remoteVer = next(filter(lambda ver: ver["prerelease"] and ver["tag_name"].startswith("v") and ver["tag_name"].find("-pre"), remoteVersions), None)
self.remoteVer = next(filter(lambda ver:ver["tag_name"].startswith("v"), remoteVersions), None)
else:
logger.error("release type: NOT FOUND")
raise ValueError("no valid branch found")
@@ -140,47 +132,54 @@ class Updater:
async def do_update(self):
logger.debug("Starting update.")
version = self.remoteVer["tag_name"]
download_url = self.remoteVer["assets"][0]["browser_download_url"]
download_url = None
download_filename = "PluginLoader" if ON_LINUX else "PluginLoader.exe"
download_temp_filename = download_filename + ".new"
for x in self.remoteVer["assets"]:
if x["name"] == download_filename:
download_url = x["browser_download_url"]
break
if download_url == None:
raise Exception("Download url not found")
service_url = self.get_service_url()
logger.debug("Retrieved service URL")
tab = await get_gamepadui_tab()
await tab.open_websocket()
async with ClientSession() as web:
logger.debug("Downloading systemd service")
# download the relevant systemd service depending upon branch
async with web.request("GET", service_url, ssl=helpers.get_ssl_context(), allow_redirects=True) as res:
logger.debug("Downloading service file")
data = await res.content.read()
logger.debug(str(data))
service_file_path = path.join(getcwd(), "plugin_loader.service")
try:
with open(path.join(getcwd(), "plugin_loader.service"), "wb") as out:
out.write(data)
except Exception as e:
logger.error(f"Error at %s", exc_info=e)
with open(path.join(getcwd(), "plugin_loader.service"), 'r') as service_file:
service_data = service_file.read()
service_data = service_data.replace("${HOMEBREW_FOLDER}", "/home/"+helpers.get_user()+"/homebrew")
with open(path.join(getcwd(), "plugin_loader.service"), 'w') as service_file:
service_file.write(service_data)
if ON_LINUX:
logger.debug("Downloading systemd service")
# download the relevant systemd service depending upon branch
async with web.request("GET", service_url, ssl=helpers.get_ssl_context(), allow_redirects=True) as res:
logger.debug("Downloading service file")
data = await res.content.read()
logger.debug(str(data))
service_file_path = path.join(getcwd(), "plugin_loader.service")
try:
with open(path.join(getcwd(), "plugin_loader.service"), "wb") as out:
out.write(data)
except Exception as e:
logger.error(f"Error at %s", exc_info=e)
with open(path.join(getcwd(), "plugin_loader.service"), "r", encoding="utf-8") as service_file:
service_data = service_file.read()
service_data = service_data.replace("${HOMEBREW_FOLDER}", helpers.get_homebrew_path())
with open(path.join(getcwd(), "plugin_loader.service"), "w", encoding="utf-8") as service_file:
service_file.write(service_data)
logger.debug("Saved service file")
logger.debug("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("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")
async with web.request("GET", download_url, ssl=helpers.get_ssl_context(), allow_redirects=True) as res:
total = int(res.headers.get('content-length', 0))
# 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:
with open(path.join(getcwd(), download_temp_filename), "wb") as out:
progress = 0
raw = 0
async for c in res.content.iter_chunked(512):
@@ -191,15 +190,18 @@ class Updater:
self.context.loop.create_task(tab.evaluate_js(f"window.DeckyUpdater.updateProgress({new_progress})", False, False, False))
progress = new_progress
with open(path.join(getcwd(), ".loader.version"), "w") as out:
with open(path.join(getcwd(), ".loader.version"), "w", encoding="utf-8") as out:
out.write(version)
call(['chmod', '+x', path.join(getcwd(), "PluginLoader")])
if ON_LINUX:
remove(path.join(getcwd(), download_filename))
shutil.move(path.join(getcwd(), download_temp_filename), path.join(getcwd(), download_filename))
chmod(path.join(getcwd(), download_filename), 777, False)
logger.info("Updated loader installation.")
await tab.evaluate_js("window.DeckyUpdater.finish()", False, False)
await self.do_restart()
await tab.close_websocket()
async def do_restart(self):
call(["systemctl", "daemon-reload"])
call(["systemctl", "restart", "plugin_loader"])
await service_restart("plugin_loader")
+25 -8
View File
@@ -7,10 +7,10 @@ from asyncio import sleep, start_server, gather, open_connection
from aiohttp import ClientSession, web
from logging import getLogger
from injector import inject_to_tab, get_gamepadui_tab
from injector import inject_to_tab, get_gamepadui_tab, close_old_tabs
import helpers
import subprocess
from localplatform import service_stop, service_start
class Utilities:
def __init__(self, context) -> None:
@@ -81,10 +81,11 @@ class Utilities:
async def http_request(self, method="", url="", **kwargs):
async with ClientSession() as web:
res = await web.request(method, url, ssl=helpers.get_ssl_context(), **kwargs)
text = await res.text()
return {
"status": res.status,
"headers": dict(res.headers),
"body": await res.text()
"body": text
}
async def ping(self, **kwargs):
@@ -173,11 +174,11 @@ class Utilities:
return self.context.settings.setSetting(key, value)
async def allow_remote_debugging(self):
await helpers.start_systemd_unit(helpers.REMOTE_DEBUGGER_UNIT)
await service_start(helpers.REMOTE_DEBUGGER_UNIT)
return True
async def disallow_remote_debugging(self):
await helpers.stop_systemd_unit(helpers.REMOTE_DEBUGGER_UNIT)
await service_stop(helpers.REMOTE_DEBUGGER_UNIT)
return True
async def filepicker_ls(self, path, include_files=True):
@@ -232,7 +233,7 @@ class Utilities:
self.rdt_proxy_server.close()
self.rdt_proxy_task.cancel()
async def enable_rdt(self):
async def _enable_rdt(self):
# TODO un-hardcode port
try:
self.stop_rdt_proxy()
@@ -242,14 +243,26 @@ class Utilities:
self.logger.info("Connecting to React DevTools at " + ip)
async with ClientSession() as web:
res = await web.request("GET", "http://" + ip + ":8097", ssl=helpers.get_ssl_context())
script = """
if (!window.deckyHasConnectedRDT) {
window.deckyHasConnectedRDT = true;
// This fixes the overlay when hovering over an element in RDT
Object.defineProperty(window, '__REACT_DEVTOOLS_TARGET_WINDOW__', {
enumerable: true,
configurable: true,
get: function() {
return (GamepadNavTree?.m_context?.m_controller || FocusNavController)?.m_ActiveContext?.ActiveWindow || window;
}
});
""" + await res.text() + "\n}"
if res.status != 200:
self.logger.error("Failed to connect to React DevTools at " + ip)
return False
self.start_rdt_proxy(ip, 8097)
script = "if(!window.deckyHasConnectedRDT){window.deckyHasConnectedRDT=true;\n" + await res.text() + "\n}"
self.logger.info("Connected to React DevTools, loading script")
tab = await get_gamepadui_tab()
# RDT needs to load before React itself to work.
await close_old_tabs()
result = await tab.reload_and_evaluate(script)
self.logger.info(result)
@@ -257,9 +270,13 @@ class Utilities:
self.logger.error("Failed to connect to React DevTools")
self.logger.error(format_exc())
async def enable_rdt(self):
self.context.loop.create_task(self._enable_rdt())
async def disable_rdt(self):
self.logger.info("Disabling React DevTools")
tab = await get_gamepadui_tab()
self.rdt_script_id = None
await tab.evaluate_js("SteamClient.User.StartRestart();", False, True, False)
await close_old_tabs()
await tab.evaluate_js("location.reload();", False, True, False)
self.logger.info("React DevTools disabled")
+4 -1
View File
@@ -11,10 +11,12 @@ HOMEBREW_FOLDER="${USER_DIR}/homebrew"
rm -rf "${HOMEBREW_FOLDER}/services"
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/services"
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/plugins"
touch "${USER_DIR}/.steam/steam/.cef-enable-remote-debugging"
# Download latest release and install it
RELEASE=$(curl -s 'https://api.github.com/repos/SteamDeckHomebrew/decky-loader/releases' | jq -r "first(.[] | select(.prerelease == "true"))")
read VERSION DOWNLOADURL < <(echo $(jq -r '.tag_name, .assets[].browser_download_url' <<< ${RELEASE}))
VERSION=$(jq -r '.tag_name' <<< ${RELEASE} )
DOWNLOADURL=$(jq -r '.assets[].browser_download_url | select(endswith("PluginLoader"))' <<< ${RELEASE})
printf "Installing version %s...\n" "${VERSION}"
curl -L $DOWNLOADURL --output ${HOMEBREW_FOLDER}/services/PluginLoader
@@ -40,6 +42,7 @@ User=root
Restart=always
ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
WorkingDirectory=${HOMEBREW_FOLDER}/services
KillSignal=SIGKILL
Environment=PLUGIN_PATH=${HOMEBREW_FOLDER}/plugins
Environment=LOG_LEVEL=DEBUG
[Install]
+4 -1
View File
@@ -11,10 +11,12 @@ HOMEBREW_FOLDER="${USER_DIR}/homebrew"
rm -rf "${HOMEBREW_FOLDER}/services"
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/services"
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/plugins"
touch "${USER_DIR}/.steam/steam/.cef-enable-remote-debugging"
# Download latest release and install it
RELEASE=$(curl -s 'https://api.github.com/repos/SteamDeckHomebrew/decky-loader/releases' | jq -r "first(.[] | select(.prerelease == "false"))")
read VERSION DOWNLOADURL < <(echo $(jq -r '.tag_name, .assets[].browser_download_url' <<< ${RELEASE}))
VERSION=$(jq -r '.tag_name' <<< ${RELEASE} )
DOWNLOADURL=$(jq -r '.assets[].browser_download_url | select(endswith("PluginLoader"))' <<< ${RELEASE})
printf "Installing version %s...\n" "${VERSION}"
curl -L $DOWNLOADURL --output ${HOMEBREW_FOLDER}/services/PluginLoader
@@ -40,6 +42,7 @@ User=root
Restart=always
ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
WorkingDirectory=${HOMEBREW_FOLDER}/services
KillSignal=SIGKILL
Environment=PLUGIN_PATH=${HOMEBREW_FOLDER}/plugins
Environment=LOG_LEVEL=INFO
[Install]
+2 -1
View File
@@ -8,7 +8,8 @@ User=root
Restart=always
ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
WorkingDirectory=${HOMEBREW_FOLDER}/services
KillSignal=SIGKILL
Environment=PLUGIN_PATH=${HOMEBREW_FOLDER}/plugins
Environment=LOG_LEVEL=DEBUG
[Install]
WantedBy=multi-user.target
WantedBy=multi-user.target
+2 -1
View File
@@ -8,7 +8,8 @@ User=root
Restart=always
ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
WorkingDirectory=${HOMEBREW_FOLDER}/services
KillSignal=SIGKILL
Environment=PLUGIN_PATH=${HOMEBREW_FOLDER}/plugins
Environment=LOG_LEVEL=INFO
[Install]
WantedBy=multi-user.target
WantedBy=multi-user.target
+162
View File
@@ -0,0 +1,162 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="81.700577mm"
height="24.334814mm"
viewBox="0 0 81.700577 24.334814"
version="1.1"
id="svg5"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="download.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#505050"
bordercolor="#ffffff"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
inkscape:deskcolor="#505050"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="3.659624"
inkscape:cx="115.44902"
inkscape:cy="59.295709"
inkscape:window-width="1827"
inkscape:window-height="1233"
inkscape:window-x="69"
inkscape:window-y="38"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" />
<defs
id="defs2">
<linearGradient
inkscape:collect="always"
id="linearGradient4494">
<stop
style="stop-color:#009fff;stop-opacity:1;"
offset="0"
id="stop4490" />
<stop
style="stop-color:#ff1965;stop-opacity:1;"
offset="0.79417855"
id="stop4498" />
<stop
style="stop-color:#b9b500;stop-opacity:1;"
offset="1"
id="stop4492" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient4494"
id="linearGradient4496"
x1="49.131042"
y1="118.6573"
x2="150.29259"
y2="138.74957"
gradientUnits="userSpaceOnUse"
spreadMethod="pad"
gradientTransform="matrix(1.0500324,0,0,1,-1.6155884,24.621921)" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient4494"
id="linearGradient13802"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(1.0500324,0,0,1,-1.6155884,24.621921)"
x1="49.131042"
y1="118.6573"
x2="150.29259"
y2="138.74957"
spreadMethod="pad" />
</defs>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-64.149712,-136.3326)">
<rect
style="mix-blend-mode:normal;fill:url(#linearGradient13802);fill-opacity:1;stroke:none;stroke-width:0.271121"
id="rect111"
width="81.700577"
height="24.334814"
x="64.149712"
y="136.3326"
ry="8.1781616" />
<text
xml:space="preserve"
style="font-size:3.175px;fill:#000000;stroke:none;stroke-width:0.264583"
x="66.364288"
y="124.84658"
id="text10382"><tspan
sodipodi:role="line"
id="tspan10380"
style="stroke-width:0.264583"
x="66.364288"
y="124.84658" /></text>
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:15.1694px;font-family:sans-serif;-inkscape-font-specification:sans-serif;white-space:pre;inline-size:82.6483;display:inline;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.264583"
x="67.732498"
y="126.05277"
id="text10440"
transform="translate(1.088576,28.135753)"><tspan
x="67.732498"
y="126.05277"
id="tspan13872">Download</tspan></text>
<rect
style="mix-blend-mode:normal;fill:url(#linearGradient4496);fill-opacity:1;stroke:none;stroke-width:0.271121"
id="rect13792"
width="81.700577"
height="24.334814"
x="64.149712"
y="136.3326"
ry="8.1781616" />
<text
xml:space="preserve"
style="font-size:3.175px;fill:#000000;stroke:none;stroke-width:0.264583"
x="66.364288"
y="124.84658"
id="text13796"><tspan
sodipodi:role="line"
id="tspan13794"
style="stroke-width:0.264583"
x="66.364288"
y="124.84658" /></text>
<g
aria-label="Download"
transform="translate(1.088576,28.135753)"
id="text13800"
style="font-size:15.1694px;-inkscape-font-specification:sans-serif;white-space:pre;inline-size:82.6483;display:inline;fill:#ffffff;stroke-width:0.264583">
<path
d="m 77.880751,120.53111 q 0,2.74566 -1.501771,4.14125 -1.486601,1.38041 -4.156416,1.38041 h -3.01871 v -10.83095 h 3.337268 q 1.638295,0 2.836678,0.60678 1.198382,0.60677 1.850666,1.78999 0.652285,1.16804 0.652285,2.91252 z m -1.441093,0.0455 q 0,-2.16923 -1.077028,-3.17041 -1.061858,-1.01635 -3.01871,-1.01635 H 70.5691 v 8.49487 h 1.471432 q 4.399126,0 4.399126,-4.30811 z"
id="path13828" />
<path
d="m 87.164417,121.9722 q 0,2.01753 -1.03152,3.1249 -1.01635,1.10737 -2.760831,1.10737 -1.077027,0 -1.926513,-0.48542 -0.834317,-0.50059 -1.319738,-1.4411 -0.485421,-0.95567 -0.485421,-2.30575 0,-2.01753 1.01635,-3.10972 1.01635,-1.0922 2.760831,-1.0922 1.107366,0 1.941683,0.50059 0.849486,0.48542 1.319738,1.42592 0.485421,0.92534 0.485421,2.27541 z m -6.143608,0 q 0,1.4411 0.561268,2.29058 0.576437,0.83432 1.820328,0.83432 1.228722,0 1.805159,-0.83432 0.576437,-0.84948 0.576437,-2.29058 0,-1.44109 -0.576437,-2.26024 -0.576437,-0.81914 -1.820328,-0.81914 -1.243891,0 -1.805159,0.81914 -0.561268,0.81915 -0.561268,2.26024 z"
id="path13830" />
<path
d="m 94.218174,121.45644 q -0.197202,-0.62194 -0.348896,-1.21355 -0.136525,-0.60677 -0.212372,-0.9405 h -0.06068 q -0.06068,0.33373 -0.197203,0.9405 -0.136524,0.59161 -0.348896,1.22872 l -1.456262,4.56599 h -1.51694 l -2.229902,-8.1308 h 1.380415 l 1.122536,4.33845 q 0.166863,0.65229 0.318557,1.31974 0.151694,0.66745 0.212372,1.10737 h 0.06068 q 0.06068,-0.25788 0.136525,-0.63712 0.09102,-0.37923 0.197202,-0.78881 0.106186,-0.42474 0.212372,-0.75847 l 1.441093,-4.58116 h 1.456262 l 1.395585,4.58116 q 0.166864,0.51576 0.318558,1.12254 0.166863,0.60678 0.227541,1.04669 h 0.06068 q 0.04551,-0.37924 0.197202,-1.04669 0.166864,-0.66745 0.348897,-1.36525 l 1.137705,-4.33845 h 1.365246 l -2.260241,8.1308 h -1.562448 z"
id="path13832" />
<path
d="m 104.8064,117.77028 q 1.45627,0 2.19957,0.71296 0.7433,0.69779 0.7433,2.27541 v 5.29412 h -1.31974 v -5.2031 q 0,-1.95685 -1.82033,-1.95685 -1.35007,0 -1.86583,0.75847 -0.51576,0.75847 -0.51576,2.18439 v 4.21709 h -1.33491 v -8.1308 h 1.07703 l 0.1972,1.10737 h 0.0759 q 0.3944,-0.63711 1.09219,-0.9405 0.69779,-0.31856 1.47143,-0.31856 z"
id="path13834" />
<path
d="m 111.6023,126.05277 h -1.33491 v -11.52874 h 1.33491 z"
id="path13836" />
<path
d="m 121.25003,121.9722 q 0,2.01753 -1.03152,3.1249 -1.01635,1.10737 -2.76084,1.10737 -1.07702,0 -1.92651,-0.48542 -0.83432,-0.50059 -1.31974,-1.4411 -0.48542,-0.95567 -0.48542,-2.30575 0,-2.01753 1.01635,-3.10972 1.01635,-1.0922 2.76083,-1.0922 1.10737,0 1.94169,0.50059 0.84948,0.48542 1.31973,1.42592 0.48543,0.92534 0.48543,2.27541 z m -6.14361,0 q 0,1.4411 0.56127,2.29058 0.57643,0.83432 1.82032,0.83432 1.22873,0 1.80516,-0.83432 0.57644,-0.84948 0.57644,-2.29058 0,-1.44109 -0.57644,-2.26024 -0.57643,-0.81914 -1.82033,-0.81914 -1.24389,0 -1.80515,0.81914 -0.56127,0.81915 -0.56127,2.26024 z"
id="path13838" />
<path
d="m 126.43796,117.78545 q 1.4866,0 2.19956,0.65228 0.71296,0.65229 0.71296,2.07821 v 5.53683 h -0.97084 l -0.25788,-1.15287 h -0.0607 q -0.53093,0.66745 -1.12253,0.98601 -0.57644,0.31856 -1.60796,0.31856 -1.10737,0 -1.8355,-0.57644 -0.72813,-0.59161 -0.72813,-1.8355 0,-1.21355 0.95567,-1.86583 0.95567,-0.66746 2.94287,-0.72814 l 1.38041,-0.0455 v -0.48542 q 0,-1.01635 -0.43991,-1.41076 -0.43991,-0.3944 -1.24389,-0.3944 -0.63712,0 -1.21355,0.1972 -0.57644,0.18203 -1.07703,0.42474 l -0.40957,-1.00118 q 0.53092,-0.28822 1.25906,-0.48542 0.72813,-0.21237 1.51694,-0.21237 z m 0.3944,4.33845 q -1.51694,0.0607 -2.10855,0.48542 -0.57643,0.42474 -0.57643,1.19838 0,0.68262 0.40957,1.00118 0.42474,0.31856 1.07703,0.31856 1.03152,0 1.71414,-0.56127 0.68262,-0.57644 0.68262,-1.75965 v -0.72813 z"
id="path13840" />
<path
d="m 134.7508,126.20447 q -1.51694,0 -2.42711,-1.04669 -0.91016,-1.06186 -0.91016,-3.15524 0,-2.09337 0.91016,-3.15523 0.92534,-1.07703 2.44228,-1.07703 0.9405,0 1.53211,0.3489 0.60677,0.34889 0.98601,0.84948 h 0.091 q -0.0152,-0.1972 -0.0607,-0.57643 -0.0303,-0.39441 -0.0303,-0.62195 v -3.24625 h 1.3349 v 11.52874 h -1.07702 l -0.19721,-1.09219 h -0.0607 q -0.36407,0.51576 -0.97084,0.87982 -0.60678,0.36407 -1.56245,0.36407 z m 0.21237,-1.10737 q 1.2894,0 1.80516,-0.69779 0.53093,-0.71296 0.53093,-2.13889 v -0.24271 q 0,-1.51694 -0.50059,-2.32092 -0.50059,-0.81914 -1.85067,-0.81914 -1.07703,0 -1.62313,0.86465 -0.53093,0.84949 -0.53093,2.29058 0,1.45626 0.53093,2.26024 0.5461,0.80398 1.6383,0.80398 z"
id="path13842" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

+2
View File
@@ -0,0 +1,2 @@
declare module '*.png';
declare module '*.jpg';
+13 -12
View File
@@ -12,27 +12,28 @@
},
"devDependencies": {
"@rollup/plugin-commonjs": "^21.1.0",
"@rollup/plugin-image": "^3.0.2",
"@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-node-resolve": "^13.3.0",
"@rollup/plugin-replace": "^4.0.0",
"@rollup/plugin-typescript": "^8.3.3",
"@rollup/plugin-typescript": "^8.5.0",
"@types/react": "16.14.0",
"@types/react-file-icon": "^1.0.1",
"@types/react-router": "5.1.18",
"@types/webpack": "^5.28.0",
"husky": "^8.0.1",
"@types/webpack": "^5.28.1",
"husky": "^8.0.3",
"import-sort-style-module": "^6.0.0",
"inquirer": "^8.2.4",
"prettier": "^2.7.1",
"inquirer": "^8.2.5",
"prettier": "^2.8.7",
"prettier-plugin-import-sort": "^0.0.7",
"react": "16.14.0",
"react-dom": "16.14.0",
"rollup": "^2.76.0",
"rollup": "^2.79.1",
"rollup-plugin-delete": "^2.0.0",
"rollup-plugin-external-globals": "^0.6.1",
"rollup-plugin-polyfill-node": "^0.10.2",
"tslib": "^2.4.0",
"typescript": "^4.7.4"
"tslib": "^2.5.0",
"typescript": "^4.9.5"
},
"importSort": {
".js, .jsx, .ts, .tsx": {
@@ -41,10 +42,10 @@
}
},
"dependencies": {
"decky-frontend-lib": "^3.7.14",
"react-file-icon": "^1.2.0",
"react-icons": "^4.4.0",
"react-markdown": "^8.0.3",
"decky-frontend-lib": "3.20.5",
"react-file-icon": "^1.3.0",
"react-icons": "^4.8.0",
"react-markdown": "^8.0.6",
"remark-gfm": "^3.0.1"
}
}
+799 -727
View File
File diff suppressed because it is too large Load Diff
+15 -16
View File
@@ -1,30 +1,28 @@
import commonjs from '@rollup/plugin-commonjs';
import image from '@rollup/plugin-image';
import json from '@rollup/plugin-json';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import externalGlobals from "rollup-plugin-external-globals";
import del from 'rollup-plugin-delete'
import replace from '@rollup/plugin-replace';
import typescript from '@rollup/plugin-typescript';
import { defineConfig, handleWarning } from 'rollup';
import { defineConfig } from 'rollup';
import del from 'rollup-plugin-delete';
import externalGlobals from 'rollup-plugin-external-globals';
const hiddenWarnings = [
"THIS_IS_UNDEFINED",
"EVAL"
];
const hiddenWarnings = ['THIS_IS_UNDEFINED', 'EVAL'];
export default defineConfig({
input: 'src/index.tsx',
plugins: [
del({ targets: "../backend/static/*", force: true }),
del({ targets: '../backend/static/*', force: true }),
commonjs(),
nodeResolve(),
externalGlobals({
react: 'SP_REACT',
'react-dom': 'SP_REACTDOM',
// hack to shut up react-markdown
'process': '{cwd: () => {}}',
'path': '{dirname: () => {}, join: () => {}, basename: () => {}, extname: () => {}}',
'url': '{fileURLToPath: (f) => f}'
process: '{cwd: () => {}}',
path: '{dirname: () => {}, join: () => {}, basename: () => {}, extname: () => {}}',
url: '{fileURLToPath: (f) => f}',
}),
typescript(),
json(),
@@ -32,17 +30,18 @@ export default defineConfig({
preventAssignment: false,
'process.env.NODE_ENV': JSON.stringify('production'),
}),
image(),
],
preserveEntrySignatures: false,
output: {
dir: '../backend/static',
format: 'esm',
chunkFileNames: (chunkInfo) => {
return 'chunk-[hash].js'
}
return 'chunk-[hash].js';
},
},
onwarn: function ( message ) {
if (hiddenWarnings.some(warning => message.code === warning)) return;
onwarn: function (message, handleWarning) {
if (hiddenWarnings.some((warning) => message.code === warning)) return;
handleWarning(message);
}
},
});
+37
View File
@@ -0,0 +1,37 @@
export default function DeckyIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 456" width="512" height="456">
<g>
<path
style={{ fill: 'none' }}
d="M154.33,72.51v49.79c11.78-0.17,23.48,2,34.42,6.39c10.93,4.39,20.89,10.91,29.28,19.18
c8.39,8.27,15.06,18.13,19.61,29c4.55,10.87,6.89,22.54,6.89,34.32c0,11.78-2.34,23.45-6.89,34.32
c-4.55,10.87-11.21,20.73-19.61,29c-8.39,8.27-18.35,14.79-29.28,19.18c-10.94,4.39-22.63,6.56-34.42,6.39v49.77
c36.78,0,72.05-14.61,98.05-40.62c26-26.01,40.61-61.28,40.61-98.05c0-36.78-14.61-72.05-40.61-98.05
C226.38,87.12,191.11,72.51,154.33,72.51z"
/>
<ellipse
transform="matrix(0.982 -0.1891 0.1891 0.982 -37.1795 32.9988)"
style={{ fill: 'none' }}
cx="154.33"
cy="211.33"
rx="69.33"
ry="69.33"
/>
<path style={{ fill: 'none' }} d="M430,97h-52v187h52c7.18,0,13-5.82,13-13V110C443,102.82,437.18,97,430,97z" />
<path
style={{ fill: 'currentColor' }}
d="M432,27h-54V0H0v361c0,52.47,42.53,95,95,95h188c52.47,0,95-42.53,95-95v-7h54c44.18,0,80-35.82,80-80V107
C512,62.82,476.18,27,432,27z M85,211.33c0-38.29,31.04-69.33,69.33-69.33c38.29,0,69.33,31.04,69.33,69.33
c0,38.29-31.04,69.33-69.33,69.33C116.04,280.67,85,249.62,85,211.33z M252.39,309.23c-26.01,26-61.28,40.62-98.05,40.62v-49.77
c11.78,0.17,23.48-2,34.42-6.39c10.93-4.39,20.89-10.91,29.28-19.18c8.39-8.27,15.06-18.13,19.61-29
c4.55-10.87,6.89-22.53,6.89-34.32c0-11.78-2.34-23.45-6.89-34.32c-4.55-10.87-11.21-20.73-19.61-29
c-8.39-8.27-18.35-14.79-29.28-19.18c-10.94-4.39-22.63-6.56-34.42-6.39V72.51c36.78,0,72.05,14.61,98.05,40.61
c26,26.01,40.61,61.28,40.61,98.05C293,247.96,278.39,283.23,252.39,309.23z M443,271c0,7.18-5.82,13-13,13h-52V97h52
c7.18,0,13,5.82,13,13V271z"
/>
</g>
</svg>
);
}
+18 -1
View File
@@ -6,6 +6,7 @@ import { VerInfo } from '../updater';
interface PublicDeckyState {
plugins: Plugin[];
pluginOrder: string[];
activePlugin: Plugin | null;
updates: PluginUpdateMapping | null;
hasLoaderUpdate?: boolean;
@@ -15,6 +16,7 @@ interface PublicDeckyState {
export class DeckyState {
private _plugins: Plugin[] = [];
private _pluginOrder: string[] = [];
private _activePlugin: Plugin | null = null;
private _updates: PluginUpdateMapping | null = null;
private _hasLoaderUpdate: boolean = false;
@@ -26,6 +28,7 @@ export class DeckyState {
publicState(): PublicDeckyState {
return {
plugins: this._plugins,
pluginOrder: this._pluginOrder,
activePlugin: this._activePlugin,
updates: this._updates,
hasLoaderUpdate: this._hasLoaderUpdate,
@@ -44,6 +47,11 @@ export class DeckyState {
this.notifyUpdate();
}
setPluginOrder(pluginOrder: string[]) {
this._pluginOrder = pluginOrder;
this.notifyUpdate();
}
setActivePlugin(name: string) {
this._activePlugin = this._plugins.find((plugin) => plugin.name === name) ?? null;
this.notifyUpdate();
@@ -78,6 +86,7 @@ interface DeckyStateContext extends PublicDeckyState {
setVersionInfo(versionInfo: VerInfo): void;
setIsLoaderUpdating(hasUpdate: boolean): void;
setActivePlugin(name: string): void;
setPluginOrder(pluginOrder: string[]): void;
closeActivePlugin(): void;
}
@@ -106,10 +115,18 @@ export const DeckyStateContextProvider: FC<Props> = ({ children, deckyState }) =
const setVersionInfo = (versionInfo: VerInfo) => deckyState.setVersionInfo(versionInfo);
const setActivePlugin = (name: string) => deckyState.setActivePlugin(name);
const closeActivePlugin = () => deckyState.closeActivePlugin();
const setPluginOrder = (pluginOrder: string[]) => deckyState.setPluginOrder(pluginOrder);
return (
<DeckyStateContext.Provider
value={{ ...publicDeckyState, setIsLoaderUpdating, setVersionInfo, setActivePlugin, closeActivePlugin }}
value={{
...publicDeckyState,
setIsLoaderUpdating,
setVersionInfo,
setActivePlugin,
closeActivePlugin,
setPluginOrder,
}}
>
{children}
</DeckyStateContext.Provider>
+2 -2
View File
@@ -1,4 +1,4 @@
import { Focusable, Router } from 'decky-frontend-lib';
import { Focusable, Navigation } from 'decky-frontend-lib';
import { FunctionComponent, useRef } from 'react';
import ReactMarkdown, { Options as ReactMarkdownOptions } from 'react-markdown';
import remarkGfm from 'remark-gfm';
@@ -22,7 +22,7 @@ const Markdown: FunctionComponent<MarkdownProps> = (props) => {
onActivate={() => {}}
onOKButton={() => {
props.onDismiss?.();
Router.NavigateToExternalWeb(aRef.current!.href);
Navigation.NavigateToExternalWeb(aRef.current!.href);
}}
style={{ display: 'inline' }}
>
+13 -3
View File
@@ -7,17 +7,27 @@ import {
scrollClasses,
staticClasses,
} from 'decky-frontend-lib';
import { VFC } from 'react';
import { VFC, useEffect, useState } from 'react';
import { Plugin } from '../plugin';
import { useDeckyState } from './DeckyState';
import NotificationBadge from './NotificationBadge';
import { useQuickAccessVisible } from './QuickAccessVisibleState';
import TitleView from './TitleView';
const PluginView: VFC = () => {
const { plugins, updates, activePlugin, setActivePlugin, closeActivePlugin } = useDeckyState();
const { plugins, updates, activePlugin, pluginOrder, setActivePlugin, closeActivePlugin } = useDeckyState();
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) {
return (
<Focusable onCancelButton={closeActivePlugin}>
@@ -36,7 +46,7 @@ const PluginView: VFC = () => {
<TitleView />
<div className={joinClassNames(staticClasses.TabGroupPanel, scrollClasses.ScrollPanel, scrollClasses.ScrollY)}>
<PanelSection>
{plugins
{pluginList
.filter((p) => p.content)
.map(({ name, icon }) => (
<PanelSectionRow key={name}>
@@ -4,15 +4,11 @@ const QuickAccessVisibleState = createContext<boolean>(true);
export const useQuickAccessVisible = () => useContext(QuickAccessVisibleState);
export const QuickAccessVisibleStateProvider: FC<{ initial: boolean; setter: ((val: boolean) => {}[]) | never[] }> = ({
children,
initial,
setter,
}) => {
export const QuickAccessVisibleStateProvider: FC<{ initial: boolean; tab: any }> = ({ children, initial, tab }) => {
const [visible, setVisible] = useState<boolean>(initial);
const [prev, setPrev] = useState<boolean>(initial);
// hack to use an array as a "pointer" to pass the setter up the tree
setter[0] = setVisible;
// HACK but i can't think of a better way to do this
tab.qAMVisibilitySetter = setVisible;
if (initial != prev) {
setPrev(initial);
setVisible(initial);
+8 -7
View File
@@ -1,6 +1,7 @@
import { DialogButton, Focusable, Router, staticClasses } from 'decky-frontend-lib';
import { CSSProperties, VFC } from 'react';
import { FaArrowLeft, FaCog, FaStore } from 'react-icons/fa';
import { BsGearFill } from 'react-icons/bs';
import { FaArrowLeft, FaStore } from 'react-icons/fa';
import { useDeckyState } from './DeckyState';
@@ -26,12 +27,6 @@ const TitleView: VFC = () => {
if (activePlugin === null) {
return (
<Focusable style={titleStyles} className={staticClasses.Title}>
<DialogButton
style={{ height: '28px', width: '40px', minWidth: 0, padding: '10px 12px' }}
onClick={onSettingsClick}
>
<FaCog style={{ marginTop: '-4px', display: 'block' }} />
</DialogButton>
<div style={{ marginRight: 'auto', flex: 0.9 }}>Decky</div>
<DialogButton
style={{ height: '28px', width: '40px', minWidth: 0, padding: '10px 12px' }}
@@ -39,6 +34,12 @@ const TitleView: VFC = () => {
>
<FaStore style={{ marginTop: '-4px', display: 'block' }} />
</DialogButton>
<DialogButton
style={{ height: '28px', width: '40px', minWidth: 0, padding: '10px 12px' }}
onClick={onSettingsClick}
>
<BsGearFill style={{ marginTop: '-4px', display: 'block' }} />
</DialogButton>
</Focusable>
);
}
@@ -1,4 +1,4 @@
import { ConfirmModal, QuickAccessTab, Router, Spinner, staticClasses } from 'decky-frontend-lib';
import { ConfirmModal, Navigation, QuickAccessTab } from 'decky-frontend-lib';
import { FC, useState } from 'react';
interface PluginInstallModalProps {
@@ -20,21 +20,20 @@ const PluginInstallModal: FC<PluginInstallModalProps> = ({ artifact, version, ha
onOK={async () => {
setLoading(true);
await onOK();
setTimeout(() => Router.OpenQuickAccessMenu(QuickAccessTab.Decky), 250);
setTimeout(() => Navigation.OpenQuickAccessMenu(QuickAccessTab.Decky), 250);
setTimeout(() => window.DeckyPluginLoader.checkPluginUpdates(), 1000);
}}
onCancel={async () => {
await onCancel();
}}
strTitle={`Install ${artifact}`}
strOKButtonText={loading ? 'Installing' : 'Install'}
>
<div className={staticClasses.Title} style={{ flexDirection: 'column' }}>
{hash == 'False' ? <h3 style={{ color: 'red' }}>!!!!NO HASH PROVIDED!!!!</h3> : null}
<div style={{ flexDirection: 'row' }}>
{loading && <Spinner style={{ width: '20px' }} />} {loading ? 'Installing' : 'Install'} {artifact}
{version ? ' version ' + version : null}
{!loading && '?'}
</div>
</div>
{hash == 'False' ? (
<h3 style={{ color: 'red' }}>!!!!NO HASH PROVIDED!!!!</h3>
) : (
`Are you sure you want to install ${artifact} ${version}?`
)}
</ConfirmModal>
);
};
@@ -1,45 +1,52 @@
import { Patch, findModuleChild, replacePatch } from 'decky-frontend-lib';
import { Patch, findModuleChild, replacePatch, sleep } from 'decky-frontend-lib';
declare global {
interface Window {
SteamClient: any;
appDetailsStore: any;
}
}
import Logger from '../../../../logger';
const logger = new Logger('LibraryPatch');
let patch: Patch;
function rePatch() {
// If you patch anything on SteamClient within the first few seconds of the client having loaded it will get redefined for some reason, so repatch any of these changes that occur within the first 20s of the last patch
// If you patch anything on SteamClient within the first few seconds of the client having loaded it will get redefined for some reason, so repatch any of these changes that occur with History.listen or an interval
patch = replacePatch(window.SteamClient.Apps, 'PromptToChangeShortcut', async ([appid]: number[]) => {
try {
const details = window.appDetailsStore.GetAppDetails(appid);
console.log(details);
logger.debug('game details', details);
// strShortcutStartDir
const file = await window.DeckyPluginLoader.openFilePicker(details.strShortcutStartDir.replaceAll('"', ''));
console.log('user selected', file);
const file = await window.DeckyPluginLoader.openFilePicker(
details?.strShortcutStartDir.replaceAll('"', '') || '/',
);
logger.debug('user selected', file);
window.SteamClient.Apps.SetShortcutExe(appid, JSON.stringify(file.path));
const pathArr = file.path.split('/');
pathArr.pop();
const folder = pathArr.join('/');
window.SteamClient.Apps.SetShortcutStartDir(appid, JSON.stringify(folder));
} catch (e) {
console.error(e);
logger.error(e);
}
});
}
// TODO type and add to frontend-lib
const History = findModuleChild((m) => {
if (typeof m !== 'object') return undefined;
for (let prop in m) {
if (m[prop]?.m_history) return m[prop].m_history;
}
});
export default async function libraryPatch() {
try {
rePatch();
// TODO type and add to frontend-lib
let History: any;
while (!History) {
History = findModuleChild((m) => {
if (typeof m !== 'object') return undefined;
for (let prop in m) {
if (m[prop]?.m_history) return m[prop].m_history;
}
});
if (!History) {
logger.debug('Waiting 5s for history to become available.');
await sleep(5000);
}
}
const unlisten = History.listen(() => {
if (window.SteamClient.Apps.PromptToChangeShortcut !== patch.patchedFunction) {
rePatch();
@@ -47,11 +54,11 @@ export default async function libraryPatch() {
});
return () => {
patch.unpatch();
unlisten();
patch.unpatch();
};
} catch (e) {
console.error('Error patching library file picker', e);
logger.error('Error patching library file picker', e);
}
return () => {};
}
+11 -7
View File
@@ -1,7 +1,9 @@
import { SidebarNavigation } from 'decky-frontend-lib';
import { lazy } from 'react';
import { FaCode, FaPlug } from 'react-icons/fa';
import { useSetting } from '../../utils/hooks/useSetting';
import DeckyIcon from '../DeckyIcon';
import WithSuspense from '../WithSuspense';
import GeneralSettings from './pages/general';
import PluginList from './pages/plugin_list';
@@ -13,19 +15,18 @@ export default function SettingsPage() {
const pages = [
{
title: 'General',
title: 'Decky',
content: <GeneralSettings isDeveloper={isDeveloper} setIsDeveloper={setIsDeveloper} />,
route: '/decky/settings/general',
icon: <DeckyIcon />,
},
{
title: 'Plugins',
content: <PluginList />,
route: '/decky/settings/plugins',
icon: <FaPlug />,
},
];
if (isDeveloper)
pages.push({
{
title: 'Developer',
content: (
<WithSuspense>
@@ -33,7 +34,10 @@ export default function SettingsPage() {
</WithSuspense>
),
route: '/decky/settings/developer',
});
icon: <FaCode />,
visible: isDeveloper,
},
];
return <SidebarNavigation title="Decky Settings" showTitle pages={pages} />;
return <SidebarNavigation pages={pages} />;
}
@@ -1,9 +1,10 @@
import { Field, Focusable, TextField, Toggle } from 'decky-frontend-lib';
import { DialogBody, Field, TextField, Toggle } from 'decky-frontend-lib';
import { useRef } from 'react';
import { FaReact, FaSteamSymbol } from 'react-icons/fa';
import { setShouldConnectToReactDevTools, setShowValveInternal } from '../../../../developer';
import { useSetting } from '../../../../utils/hooks/useSetting';
import RemoteDebuggingSettings from '../general/RemoteDebugging';
export default function DeveloperSettings() {
const [enableValveInternal, setEnableValveInternal] = useSetting<boolean>('developer.valve_internal', false);
@@ -12,7 +13,8 @@ export default function DeveloperSettings() {
const textRef = useRef<HTMLDivElement>(null);
return (
<>
<DialogBody>
<RemoteDebuggingSettings />
<Field
label="Enable Valve Internal"
description={
@@ -30,55 +32,33 @@ export default function DeveloperSettings() {
setShowValveInternal(toggleValue);
}}
/>
</Field>{' '}
<Focusable
onTouchEnd={
reactDevtoolsIP == ''
? () => {
(textRef.current?.childNodes[0] as HTMLInputElement)?.focus();
}
: undefined
}
onClick={
reactDevtoolsIP == ''
? () => {
(textRef.current?.childNodes[0] as HTMLInputElement)?.focus();
}
: undefined
}
onOKButton={
reactDevtoolsIP == ''
? () => {
(textRef.current?.childNodes[0] as HTMLInputElement)?.focus();
}
: undefined
</Field>
<Field
label="Enable React DevTools"
description={
<>
<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.
</span>
<br />
<br />
<div ref={textRef}>
<TextField label={'IP'} value={reactDevtoolsIP} onChange={(e) => setReactDevtoolsIP(e?.target.value)} />
</div>
</>
}
icon={<FaReact style={{ display: 'block' }} />}
>
<Field
label="Enable React DevTools"
description={
<>
<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.
</span>
<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>
</Focusable>
</>
<Toggle
value={reactDevtoolsEnabled}
// disabled={reactDevtoolsIP == ''}
onChange={(toggleValue) => {
setReactDevtoolsEnabled(toggleValue);
setShouldConnectToReactDevTools(toggleValue);
}}
/>
</Field>
</DialogBody>
);
}
@@ -19,7 +19,7 @@ const BranchSelect: FunctionComponent<{}> = () => {
return (
// Returns numerical values from 0 to 2 (with current branch setup as of 8/28/22)
// 0 being stable, 1 being pre-release and 2 being nightly
<Field label="Update Channel">
<Field label="Decky Update Channel" childrenContainerWidth={'fixed'}>
<Dropdown
rgOptions={Object.values(UpdateBranch)
.filter((branch) => typeof branch == 'string')
@@ -1,5 +1,5 @@
import { Field, Toggle } from 'decky-frontend-lib';
import { FaBug } from 'react-icons/fa';
import { FaChrome } from 'react-icons/fa';
import { useSetting } from '../../../../utils/hooks/useSetting';
@@ -11,10 +11,10 @@ export default function RemoteDebuggingSettings() {
label="Allow Remote CEF Debugging"
description={
<span style={{ whiteSpace: 'pre-line' }}>
Allow unauthenticated access to the CEF debugger to anyone in your network
Allows unauthenticated access to the CEF debugger to anyone in your network.
</span>
}
icon={<FaBug style={{ display: 'block' }} />}
icon={<FaChrome style={{ display: 'block' }} />}
>
<Toggle
value={allowRemoteDebugging || false}
@@ -16,7 +16,7 @@ const StoreSelect: FunctionComponent<{}> = () => {
// 0 being Default, 1 being Testing and 2 being Custom
return (
<>
<Field label="Store Channel">
<Field label="Plugin Store Channel" childrenContainerWidth={'fixed'}>
<Dropdown
rgOptions={Object.values(Store)
.filter((store) => typeof store == 'string')
@@ -6,15 +6,15 @@ import {
Focusable,
ProgressBarWithInfo,
Spinner,
findSP,
showModal,
} from 'decky-frontend-lib';
import { useCallback } from 'react';
import { Suspense, lazy } from 'react';
import { useEffect, useState } from 'react';
import { FaArrowDown } from 'react-icons/fa';
import { FaExclamation } from 'react-icons/fa';
import { VerInfo, callUpdaterMethod, finishUpdate } from '../../../../updater';
import { findSP } from '../../../../utils/windows';
import { useDeckyState } from '../../../DeckyState';
import InlinePatchNotes from '../../../patchnotes/InlinePatchNotes';
import WithSuspense from '../../../WithSuspense';
@@ -95,21 +95,21 @@ export default function UpdaterSettings() {
<Field
onOptionsActionDescription={versionInfo?.all ? 'Patch Notes' : undefined}
onOptionsButton={versionInfo?.all ? showPatchNotes : undefined}
label="Updates"
label="Decky Updates"
description={
versionInfo && (
<span style={{ whiteSpace: 'pre-line' }}>{`Current version: ${versionInfo.current}\n${
versionInfo.updatable ? `Latest version: ${versionInfo.remote?.tag_name}` : ''
}`}</span>
checkingForUpdates || versionInfo?.remote?.tag_name != versionInfo?.current || !versionInfo?.remote ? (
''
) : (
<span>Up to date: running {versionInfo?.current}</span>
)
}
icon={
!versionInfo ? (
<Spinner style={{ width: '1em', height: 20, display: 'block' }} />
) : (
<FaArrowDown style={{ display: 'block' }} />
versionInfo?.remote &&
versionInfo?.remote?.tag_name != versionInfo?.current && (
<FaExclamation color="var(--gpColor-Yellow)" style={{ display: 'block' }} />
)
}
childrenContainerWidth={'fixed'}
>
{updateProgress == -1 && !isLoaderUpdating ? (
<DialogButton
@@ -144,7 +144,7 @@ export default function UpdaterSettings() {
/>
)}
</Field>
{versionInfo?.remote && (
{versionInfo?.remote && versionInfo?.remote?.tag_name != versionInfo?.current && (
<InlinePatchNotes
title={versionInfo?.remote.name}
date={new Intl.RelativeTimeFormat('en-US', {
@@ -1,10 +1,17 @@
import { DialogButton, Field, TextField, Toggle } from 'decky-frontend-lib';
import {
DialogBody,
DialogButton,
DialogControlsSection,
DialogControlsSectionHeader,
Field,
TextField,
Toggle,
} from 'decky-frontend-lib';
import { useState } from 'react';
import { FaShapes, FaTools } from 'react-icons/fa';
import { installFromURL } from '../../../../store';
import { useDeckyState } from '../../../DeckyState';
import BranchSelect from './BranchSelect';
import RemoteDebuggingSettings from './RemoteDebugging';
import StoreSelect from './StoreSelect';
import UpdaterSettings from './Updater';
@@ -16,34 +23,44 @@ export default function GeneralSettings({
setIsDeveloper: (val: boolean) => void;
}) {
const [pluginURL, setPluginURL] = useState('');
const { versionInfo } = useDeckyState();
return (
<div>
<UpdaterSettings />
<BranchSelect />
<StoreSelect />
<RemoteDebuggingSettings />
<Field
label="Developer mode"
description={<span style={{ whiteSpace: 'pre-line' }}>Enables Decky's developer settings.</span>}
icon={<FaTools style={{ display: 'block' }} />}
>
<Toggle
value={isDeveloper}
onChange={(toggleValue) => {
setIsDeveloper(toggleValue);
}}
/>
</Field>
<Field
label="Manual plugin install"
description={<TextField label={'URL'} value={pluginURL} onChange={(e) => setPluginURL(e?.target.value)} />}
icon={<FaShapes style={{ display: 'block' }} />}
>
<DialogButton disabled={pluginURL.length == 0} onClick={() => installFromURL(pluginURL)}>
Install
</DialogButton>
</Field>
</div>
<DialogBody>
<DialogControlsSection>
<DialogControlsSectionHeader>Updates</DialogControlsSectionHeader>
<UpdaterSettings />
</DialogControlsSection>
<DialogControlsSection>
<DialogControlsSectionHeader>Beta Participation</DialogControlsSectionHeader>
<BranchSelect />
<StoreSelect />
</DialogControlsSection>
<DialogControlsSection>
<DialogControlsSectionHeader>Other</DialogControlsSectionHeader>
<Field label="Enable Developer Mode">
<Toggle
value={isDeveloper}
onChange={(toggleValue) => {
setIsDeveloper(toggleValue);
}}
/>
</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>
<DialogControlsSectionHeader>About</DialogControlsSectionHeader>
<Field label="Decky Version" focusable={true}>
<div style={{ color: 'var(--gpSystemLighterGrey)' }}>{versionInfo?.current}</div>
</Field>
</DialogControlsSection>
</DialogBody>
);
}
@@ -1,17 +1,117 @@
import { DialogButton, Focusable, Menu, MenuItem, showContextMenu } from 'decky-frontend-lib';
import { useEffect } from 'react';
import { FaDownload, FaEllipsisH } from 'react-icons/fa';
import {
DialogBody,
DialogButton,
DialogControlsSection,
GamepadEvent,
Menu,
MenuItem,
ReorderableEntry,
ReorderableList,
showContextMenu,
} from 'decky-frontend-lib';
import { useEffect, useState } from 'react';
import { FaDownload, FaEllipsisH, FaRecycle } from 'react-icons/fa';
import { requestPluginInstall } from '../../../../store';
import { StorePluginVersion, getPluginList, requestPluginInstall } from '../../../../store';
import { useSetting } from '../../../../utils/hooks/useSetting';
import { useDeckyState } from '../../../DeckyState';
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() {
const { plugins, updates } = useDeckyState();
const { plugins, updates, pluginOrder, setPluginOrder } = useDeckyState();
const [_, setPluginOrderSetting] = useSetting<string[]>(
'pluginOrder',
plugins.map((plugin) => plugin.name),
);
useEffect(() => {
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) {
return (
<div>
@@ -20,47 +120,18 @@ export default function PluginList() {
);
}
function onSave(entries: ReorderableEntry<PluginData>[]) {
const newOrder = entries.map((entry) => labelToName(entry.label, entry?.data?.version));
console.log(newOrder);
setPluginOrder(newOrder);
setPluginOrderSetting(newOrder);
}
return (
<ul style={{ listStyleType: 'none' }}>
{plugins.map(({ name, version }) => {
const update = updates?.get(name);
return (
<li style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', paddingBottom: '10px' }}>
<span>
{name} {version}
</span>
<Focusable style={{ marginLeft: 'auto', boxShadow: 'none', display: 'flex', justifyContent: 'right' }}>
{update && (
<DialogButton
style={{ height: '40px', minWidth: '60px', marginRight: '10px' }}
onClick={() => requestPluginInstall(name, update)}
>
<div style={{ display: 'flex', flexDirection: 'row' }}>
Update to {update.name}
<FaDownload style={{ paddingLeft: '2rem' }} />
</div>
</DialogButton>
)}
<DialogButton
style={{ height: '40px', width: '40px', padding: '10px 12px', minWidth: '40px' }}
onClick={(e: MouseEvent) =>
showContextMenu(
<Menu label="Plugin Actions">
<MenuItem onSelected={() => window.DeckyPluginLoader.importPlugin(name, version)}>
Reload
</MenuItem>
<MenuItem onSelected={() => window.DeckyPluginLoader.uninstallPlugin(name)}>Uninstall</MenuItem>
</Menu>,
e.currentTarget ?? window,
)
}
>
<FaEllipsisH />
</DialogButton>
</Focusable>
</li>
);
})}
</ul>
<DialogBody>
<DialogControlsSection>
<ReorderableList<PluginData> entries={pluginEntries} onSave={onSave} interactables={PluginInteractables} />
</DialogControlsSection>
</DialogBody>
);
}
+143 -152
View File
@@ -1,15 +1,12 @@
import {
DialogButton,
ButtonItem,
Dropdown,
Focusable,
QuickAccessTab,
Router,
PanelSectionRow,
SingleDropdownOption,
SuspensefulImage,
joinClassNames,
staticClasses,
} from 'decky-frontend-lib';
import { FC, useRef, useState } from 'react';
import { FC, useState } from 'react';
import { StorePlugin, StorePluginVersion, requestPluginInstall } from '../../store';
@@ -19,168 +16,162 @@ interface PluginCardProps {
const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
const [selectedOption, setSelectedOption] = useState<number>(0);
const buttonRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const root: boolean = plugin.tags.some((tag) => tag === 'root');
return (
<div
className="deckyStoreCard"
style={{
padding: '30px',
paddingTop: '10px',
paddingBottom: '10px',
marginLeft: '20px',
marginRight: '20px',
marginBottom: '20px',
display: 'flex',
alignItems: 'center',
}}
>
{/* TODO: abstract this messy focus hackiness into a custom component in lib */}
<Focusable
className="deckyStoreCard"
ref={containerRef}
onActivate={(_: CustomEvent) => {
buttonRef.current!.focus();
}}
onCancel={(_: CustomEvent) => {
if (containerRef.current!.querySelectorAll('* :focus').length === 0) {
Router.NavigateBackOrOpenMenu();
setTimeout(() => Router.OpenQuickAccessMenu(QuickAccessTab.Decky), 1000);
} else {
containerRef.current!.focus();
}
}}
<div
className="deckyStoreCardImageContainer"
style={{
display: 'flex',
flexDirection: 'column',
background: '#ACB2C924',
height: 'unset',
marginBottom: 'unset',
// boxShadow: var(--gpShadow-Medium);
scrollSnapAlign: 'start',
boxSizing: 'border-box',
width: '320px',
height: '200px',
position: 'relative',
}}
>
<div className="deckyStoreCardHeader" style={{ display: 'flex', alignItems: 'center' }}>
<div
style={{ fontSize: '18pt', padding: '10px' }}
className={joinClassNames(staticClasses.Text)}
// onClick={() => Router.NavigateToExternalWeb('https://github.com/' + plugin.artifact)}
>
{plugin.name}
</div>
</div>
<div
<SuspensefulImage
className="deckyStoreCardImage"
suspenseHeight="200px"
suspenseWidth="320px"
style={{
display: 'flex',
flexDirection: 'row',
width: '320px',
height: '200px',
objectFit: 'cover',
}}
className="deckyStoreCardBody"
>
<SuspensefulImage
className="deckyStoreCardImage"
suspenseWidth="256px"
style={{
width: 'auto',
height: '160px',
}}
src={plugin.image_url}
/>
<div
style={{
display: 'flex',
flexDirection: 'column',
}}
className="deckyStoreCardInfo"
>
<p
className={joinClassNames(staticClasses.PanelSectionRow)}
style={{ marginTop: '0px', marginLeft: '16px' }}
>
<span style={{ paddingLeft: '0px' }}>Author: {plugin.author}</span>
</p>
<p
className={joinClassNames(staticClasses.PanelSectionRow)}
style={{
marginLeft: '16px',
marginTop: '0px',
marginBottom: '0px',
marginRight: '16px',
}}
>
<span style={{ paddingLeft: '0px' }}>{plugin.description}</span>
</p>
<p
className={joinClassNames('deckyStoreCardTagsContainer', staticClasses.PanelSectionRow)}
style={{
padding: '0 16px',
display: 'flex',
flexWrap: 'wrap',
gap: '5px 10px',
}}
>
<span style={{ padding: '5px 0' }}>Tags:</span>
{plugin.tags.map((tag: string) => (
<span
className="deckyStoreCardTag"
style={{
padding: '5px',
borderRadius: '5px',
background: tag == 'root' ? '#842029' : '#ACB2C947',
}}
>
{tag == 'root' ? 'Requires root' : tag}
</span>
))}
</p>
</div>
</div>
<div
className="deckyStoreCardActionsContainer"
src={plugin.image_url}
/>
</div>
<div
className="deckyStoreCardInfo"
style={{
width: 'calc(100% - 320px)', // The calc is here so that the info section doesn't expand into the image
display: 'flex',
flexDirection: 'column',
height: '100%',
marginLeft: '1em',
justifyContent: 'center',
}}
>
<span
className="deckyStoreCardTitle"
style={{
fontSize: '1.25em',
fontWeight: 'bold',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
width: '90%',
}}
>
{plugin.name}
</span>
<span
className="deckyStoreCardAuthor"
style={{
marginRight: 'auto',
fontSize: '1em',
}}
>
{plugin.author}
</span>
<span
className="deckyStoreCardDescription"
style={{
fontSize: '13px',
color: '#969696',
WebkitLineClamp: root ? '2' : '3',
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
display: '-webkit-box',
}}
>
{plugin.description ? (
plugin.description
) : (
<span>
<i style={{ color: '#666' }}>No description provided.</i>
</span>
)}
</span>
{root && (
<span
className="deckyStoreCardDescription deckyStoreCardDescriptionRoot"
style={{
fontSize: '13px',
color: '#fee75c',
}}
>
<i>This plugin has full access to your Steam Deck.</i>{' '}
<a
className="deckyStoreCardDescriptionRootLink"
href="https://deckbrew.xyz/root"
target="_blank"
style={{
color: '#fee75c',
textDecoration: 'none',
}}
>
deckbrew.xyz/root
</a>
</span>
)}
<div
className="deckyStoreCardButtonRow"
style={{
marginTop: '1em',
width: '100%',
alignSelf: 'flex-end',
display: 'flex',
flexDirection: 'row',
overflow: 'hidden',
}}
>
<Focusable
className="deckyStoreCardActions"
style={{
display: 'flex',
flexDirection: 'row',
width: '100%',
}}
>
<div
className="deckyStoreCardInstallButtonContainer"
style={{
flex: '1',
}}
>
<DialogButton
className="deckyStoreCardInstallButton"
ref={buttonRef}
onClick={() => requestPluginInstall(plugin.name, plugin.versions[selectedOption])}
<PanelSectionRow>
<Focusable style={{ display: 'flex', maxWidth: '100%' }}>
<div
className="deckyStoreCardInstallContainer"
style={{
paddingTop: '0px',
paddingBottom: '0px',
width: '40%',
}}
>
Install
</DialogButton>
</div>
<div
className="deckyStoreCardVersionDropdownContainer"
style={{
flex: '0.2',
}}
>
<Dropdown
rgOptions={
plugin.versions.map((version: StorePluginVersion, index) => ({
data: index,
label: version.name,
})) as SingleDropdownOption[]
}
strDefaultLabel={'Select a version'}
selectedOption={selectedOption}
onChange={({ data }) => setSelectedOption(data)}
/>
</div>
</Focusable>
<ButtonItem
bottomSeparator="none"
layout="below"
onClick={() => requestPluginInstall(plugin.name, plugin.versions[selectedOption])}
>
<span className="deckyStoreCardInstallText">Install</span>
</ButtonItem>
</div>
<div
className="deckyStoreCardVersionContainer"
style={{
marginLeft: '5%',
width: '30%',
}}
>
<Dropdown
rgOptions={
plugin.versions.map((version: StorePluginVersion, index) => ({
data: index,
label: version.name,
})) as SingleDropdownOption[]
}
menuLabel="Plugin Version"
selectedOption={selectedOption}
onChange={({ data }) => setSelectedOption(data)}
/>
</div>
</Focusable>
</PanelSectionRow>
</div>
</Focusable>
</div>
</div>
);
};
+207 -19
View File
@@ -1,14 +1,29 @@
import { SteamSpinner } from 'decky-frontend-lib';
import { FC, useEffect, useState } from 'react';
import {
Dropdown,
DropdownOption,
Focusable,
PanelSectionRow,
SteamSpinner,
Tabs,
TextField,
findModule,
} from 'decky-frontend-lib';
import { FC, useEffect, useMemo, useState } from 'react';
import logo from '../../../assets/plugin_store.png';
import Logger from '../../logger';
import { StorePlugin, getPluginList } from '../../store';
import PluginCard from './PluginCard';
const logger = new Logger('FilePicker');
const logger = new Logger('Store');
const StorePage: FC<{}> = () => {
const [currentTabRoute, setCurrentTabRoute] = useState<string>('browse');
const [data, setData] = useState<StorePlugin[] | null>(null);
const { TabCount } = findModule((m) => {
if (m?.TabCount && m?.TabTitle) return true;
return false;
});
useEffect(() => {
(async () => {
@@ -19,19 +34,12 @@ const StorePage: FC<{}> = () => {
}, []);
return (
<div
style={{
marginTop: '40px',
height: 'calc( 100% - 40px )',
overflowY: 'scroll',
}}
>
<>
<div
style={{
display: 'flex',
flexWrap: 'nowrap',
flexDirection: 'column',
height: '100%',
marginTop: '40px',
height: 'calc( 100% - 40px )',
background: '#0005',
}}
>
{!data ? (
@@ -39,13 +47,193 @@ const StorePage: FC<{}> = () => {
<SteamSpinner />
</div>
) : (
<div>
{data.map((plugin: StorePlugin) => (
<PluginCard plugin={plugin} />
))}
</div>
<Tabs
activeTab={currentTabRoute}
onShowTab={(tabId: string) => {
setCurrentTabRoute(tabId);
}}
tabs={[
{
title: 'Browse',
content: <BrowseTab children={{ data: data }} />,
id: 'browse',
renderTabAddon: () => <span className={TabCount}>{data.length}</span>,
},
{
title: 'About',
content: <AboutTab />,
id: 'about',
},
]}
/>
)}
</div>
</>
);
};
const BrowseTab: FC<{ children: { data: StorePlugin[] } }> = (data) => {
const sortOptions = useMemo(
(): DropdownOption[] => [
{ data: 1, label: 'Alphabetical (A to Z)' },
{ data: 2, label: 'Alphabetical (Z to A)' },
],
[],
);
// const filterOptions = useMemo((): DropdownOption[] => [{ data: 1, label: 'All' }], []);
const [selectedSort, setSort] = useState<number>(sortOptions[0].data);
// const [selectedFilter, setFilter] = useState<number>(filterOptions[0].data);
const [searchFieldValue, setSearchValue] = useState<string>('');
return (
<>
<style>{`
.deckyStoreCardInstallContainer > .Panel {
padding: 0;
}
`}</style>
{/* This should be used once filtering is added
<PanelSectionRow>
<Focusable style={{ display: 'flex', maxWidth: '100%' }}>
<div
style={{
display: 'flex',
flexDirection: 'column',
width: '47.5%',
}}
>
<span className="DialogLabel">Sort</span>
<Dropdown
menuLabel="Sort"
rgOptions={sortOptions}
strDefaultLabel="Last Updated (Newest)"
selectedOption={selectedSort}
onChange={(e) => setSort(e.data)}
/>
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
width: '47.5%',
marginLeft: 'auto',
}}
>
<span className="DialogLabel">Filter</span>
<Dropdown
menuLabel="Filter"
rgOptions={filterOptions}
strDefaultLabel="All"
selectedOption={selectedFilter}
onChange={(e) => setFilter(e.data)}
/>
</div>
</Focusable>
</PanelSectionRow>
<div style={{ justifyContent: 'center', display: 'flex' }}>
<Focusable style={{ display: 'flex', alignItems: 'center', width: '96%' }}>
<div style={{ width: '100%' }}>
<TextField label="Search" value={searchFieldValue} onChange={(e) => setSearchValue(e.target.value)} />
</div>
</Focusable>
</div>
*/}
<PanelSectionRow>
<Focusable style={{ display: 'flex', maxWidth: '100%' }}>
<div
style={{
display: 'flex',
flexDirection: 'column',
minWidth: '100%',
maxWidth: '100%',
}}
>
<span className="DialogLabel">Sort</span>
<Dropdown
menuLabel="Sort"
rgOptions={sortOptions}
strDefaultLabel="Last Updated (Newest)"
selectedOption={selectedSort}
onChange={(e) => setSort(e.data)}
/>
</div>
</Focusable>
</PanelSectionRow>
<div style={{ justifyContent: 'center', display: 'flex' }}>
<Focusable style={{ display: 'flex', alignItems: 'center', width: '96%' }}>
<div style={{ width: '100%' }}>
<TextField label="Search" value={searchFieldValue} onChange={(e) => setSearchValue(e.target.value)} />
</div>
</Focusable>
</div>
<div>
{data.children.data
.filter((plugin: StorePlugin) => {
return (
plugin.name.toLowerCase().includes(searchFieldValue.toLowerCase()) ||
plugin.description.toLowerCase().includes(searchFieldValue.toLowerCase()) ||
plugin.author.toLowerCase().includes(searchFieldValue.toLowerCase()) ||
plugin.tags.some((tag: string) => tag.toLowerCase().includes(searchFieldValue.toLowerCase()))
);
})
.sort((a, b) => {
if (selectedSort % 2 === 1) return a.name.localeCompare(b.name);
else return b.name.localeCompare(a.name);
})
.map((plugin: StorePlugin) => (
<PluginCard plugin={plugin} />
))}
</div>
</>
);
};
const AboutTab: FC<{}> = () => {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
}}
>
<style>{`
.deckyStoreAboutHeader {
font-size: 24px;
font-weight: 600;
margin-top: 20px;
}
`}</style>
<img
src={logo}
style={{
width: '256px',
height: 'auto',
alignSelf: 'center',
}}
/>
<span className="deckyStoreAboutHeader">Testing</span>
<span>
Please consider testing new plugins to help the Decky Loader team!{' '}
<a
href="https://deckbrew.xyz/testing"
target="_blank"
style={{
textDecoration: 'none',
}}
>
deckbrew.xyz/testing
</a>
</span>
<span className="deckyStoreAboutHeader">Contributing</span>
<span>
If you would like to contribute to the Decky Plugin Store, check the SteamDeckHomebrew/decky-plugin-template
repository on GitHub. Information on development and distribution is available in the README.
</span>
<span className="deckyStoreAboutHeader">Source Code</span>
<span>All plugin source code is available on SteamDeckHomebrew/decky-plugin-database repository on GitHub.</span>
</div>
);
};
+17 -8
View File
@@ -1,4 +1,5 @@
import {
Navigation,
ReactRouter,
Router,
fakeRenderComponent,
@@ -26,13 +27,20 @@ const logger = new Logger('DeveloperMode');
let removeSettingsObserver: () => void = () => {};
export function setShowValveInternal(show: boolean) {
const settingsMod = findModuleChild((m) => {
if (typeof m !== 'object') return undefined;
for (let prop in m) {
if (typeof m[prop]?.settings?.bIsValveEmail !== 'undefined') return m[prop];
export async function setShowValveInternal(show: boolean) {
let settingsMod: any;
while (!settingsMod) {
settingsMod = findModuleChild((m) => {
if (typeof m !== 'object') return undefined;
for (let prop in m) {
if (typeof m[prop]?.settings?.bIsValveEmail !== 'undefined') return m[prop];
}
});
if (!settingsMod) {
logger.debug('[ValveInternal] waiting for settingsMod');
await sleep(1000);
}
});
}
if (show) {
removeSettingsObserver = settingsMod[
@@ -74,13 +82,14 @@ export async function startup() {
window.DFL = {
findModuleChild,
findModule,
Navigation,
Router,
ReactRouter,
ReactUtils: {
fakeRenderComponent,
findInReactTree,
findInTree,
},
Router,
ReactRouter,
classes: {
scrollClasses,
staticClasses,
+20
View File
@@ -1,3 +1,5 @@
import { Navigation, Router, sleep } from 'decky-frontend-lib';
import PluginLoader from './plugin-loader';
import { DeckyUpdater } from './updater';
@@ -14,6 +16,23 @@ declare global {
}
}
(async () => {
try {
if (!Router.NavigateToAppProperties || !Router.NavigateToLibraryTab || !Router.NavigateToInvites) {
while (!Navigation.NavigateToAppProperties) await sleep(100);
const shims = {
NavigateToAppProperties: Navigation.NavigateToAppProperties,
NavigateToInvites: Navigation.NavigateToInvites,
NavigateToLibraryTab: Navigation.NavigateToLibraryTab,
};
(Router as unknown as any).deckyShim = true;
Object.assign(Router, shims);
}
} catch (e) {
console.error('[DECKY]: Error initializing Navigation interface shims', e);
}
})();
(async () => {
window.deckyAuthToken = await fetch('http://127.0.0.1:1337/auth/token').then((r) => r.text());
@@ -37,6 +56,7 @@ declare global {
if (!window.DeckyPluginLoader.hasPlugin(plugin.name))
window.DeckyPluginLoader?.importPlugin(plugin.name, plugin.version);
}
window.DeckyPluginLoader.checkPluginUpdates();
};
+16 -17
View File
@@ -1,13 +1,4 @@
import {
ConfirmModal,
ModalRoot,
Patch,
QuickAccessTab,
Router,
showModal,
sleep,
staticClasses,
} from 'decky-frontend-lib';
import { ConfirmModal, ModalRoot, Patch, QuickAccessTab, Router, showModal, sleep } from 'decky-frontend-lib';
import { FC, lazy } from 'react';
import { FaCog, FaExclamationCircle, FaPlug } from 'react-icons/fa';
@@ -21,6 +12,7 @@ import WithSuspense from './components/WithSuspense';
import Logger from './logger';
import { Plugin } from './plugin';
import RouterHook from './router-hook';
import { deinitSteamFixes, initSteamFixes } from './steamfixes';
import { checkForUpdates } from './store';
import TabsHook from './tabs-hook';
import OldTabsHook from './tabs-hook.old';
@@ -33,10 +25,6 @@ const SettingsPage = lazy(() => import('./components/settings'));
const FilePicker = lazy(() => import('./components/modals/filepicker'));
declare global {
interface Window {}
}
class PluginLoader extends Logger {
private plugins: Plugin[] = [];
private tabsHook: TabsHook | OldTabsHook = document.title == 'SP' ? new OldTabsHook() : new TabsHook();
@@ -92,6 +80,8 @@ class PluginLoader extends Logger {
);
});
initSteamFixes();
initFilepickerPatches();
this.updateVersion();
@@ -156,10 +146,10 @@ class PluginLoader extends Logger {
onCancel={() => {
// do nothing
}}
strTitle={`Uninstall ${name}`}
strOKButtonText={'Uninstall'}
>
<div className={staticClasses.Title} style={{ flexDirection: 'column' }}>
Uninstall {name}?
</div>
Are you sure you want to uninstall {name}?
</ConfirmModal>,
);
}
@@ -179,16 +169,24 @@ class PluginLoader extends Logger {
getSetting('developer.enabled', false).then((val) => {
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() {
this.routerHook.removeRoute('/decky/store');
this.routerHook.removeRoute('/decky/settings');
deinitSteamFixes();
deinitFilepickerPatches();
this.focusWorkaroundPatch?.unpatch();
}
public unloadPlugin(name: string) {
console.log('Plugin List: ', this.plugins);
const plugin = this.plugins.find((plugin) => plugin.name === name || plugin.name === name.replace('$LEGACY_', ''));
plugin?.onDismount?.();
this.plugins = this.plugins.filter((p) => p !== plugin);
@@ -344,6 +342,7 @@ class PluginLoader extends Logger {
fetchNoCors(url: string, request: any = {}) {
let args = { method: 'POST', headers: {} };
const req = { ...args, ...request, url, data: request.body };
req?.body && delete req.body;
return this.callServerMethod('http_request', req);
},
executeInTab(tab: string, runAsync: boolean, code: string) {
+3 -5
View File
@@ -123,11 +123,9 @@ class RouterHook extends Logger {
this.wrapperPatch = afterPatch(this.gamepadWrapper, 'render', (_: any, ret: any) => {
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;
if (
ret.props.children.props.children[idx]?.props?.children?.[0]?.type?.type
?.toString()
?.includes('GamepadUI.Settings.Root()')
) {
const potentialSettingsRootString =
ret.props.children.props.children[idx]?.props?.children?.[0]?.type?.type?.toString() || '';
if (potentialSettingsRootString?.includes('Settings.Root()')) {
if (!this.router) {
this.router = ret.props.children.props.children[idx]?.props?.children?.[0]?.type;
this.routerPatch = afterPatch(this.router, 'type', (_: any, ret: any) => {
+13
View File
@@ -0,0 +1,13 @@
## What's this?
`steamfixes` contains various fixes and workaround for things Valve has broken that cause Decky issues.
## Current fixes:
- StartRestart() -> StartShutdown(false) override:
StartRestart() breaks CEF debugging, StartShutdown(false) doesn't. We can safely replace StartRestart() with StartShutdown(false) as gamescope-session will automatically restart the steam client anyway if it shuts down, bypassing the broken restart codepath. Added 12/29/2022
- ExecuteSteamURL UI reload fix:
Starting sometime in November 2022, Valve broke reloading the Steam UI pages via location.reload, as it won't properly start the UI. We can manually trigger UI startup if we detect no active input contexts by calling `SteamClient.URL.ExecuteSteamURL("steam://open/settings/")` Added 12/29/2022
+12
View File
@@ -0,0 +1,12 @@
import reloadFix from './reload';
import restartFix from './restart';
let fixes: Function[] = [];
export function deinitSteamFixes() {
fixes.forEach((deinit) => deinit());
}
export async function initSteamFixes() {
fixes.push(await reloadFix());
fixes.push(await restartFix());
}
+21
View File
@@ -0,0 +1,21 @@
import { getFocusNavController, sleep } from 'decky-frontend-lib';
import Logger from '../logger';
const logger = new Logger('ReloadSteamFix');
declare global {
var GamepadNavTree: any;
}
export default async function reloadFix() {
// Hack to unbreak the ui when reloading it
await sleep(4000);
if (getFocusNavController()?.m_rgAllContexts?.length == 0) {
SteamClient.URL.ExecuteSteamURL('steam://open/settings');
logger.log('Applied UI reload fix.');
}
// This steamfix does not need to deinit.
return () => {};
}
+53
View File
@@ -0,0 +1,53 @@
import { Patch, findModuleChild, replacePatch, sleep } from 'decky-frontend-lib';
import Logger from '../logger';
const logger = new Logger('RestartSteamFix');
let patch: Patch;
function rePatch() {
// If you patch anything on SteamClient within the first few seconds of the client having loaded it will get redefined for some reason, so repatch any of these changes that occur with History.listen or an interval
patch = replacePatch(window.SteamClient.User, 'StartRestart', () => SteamClient.User.StartShutdown(false));
}
export default async function restartFix() {
try {
rePatch();
// TODO type and add to frontend-lib
let History: any;
while (!History) {
History = findModuleChild((m) => {
if (typeof m !== 'object') return undefined;
for (let prop in m) {
if (m[prop]?.m_history) return m[prop].m_history;
}
});
if (!History) {
logger.debug('Waiting 5s for history to become available.');
await sleep(5000);
}
}
function repatchIfNeeded() {
if (window.SteamClient.User.StartRestart !== patch.patchedFunction) {
rePatch();
}
}
const unlisten = History.listen(repatchIfNeeded);
// Just in case
setTimeout(repatchIfNeeded, 5000);
setTimeout(repatchIfNeeded, 10000);
return () => {
unlisten();
patch.unpatch();
};
} catch (e) {
logger.error('Error patching StartRestart', e);
}
return () => {};
}
+4 -1
View File
@@ -10,6 +10,7 @@ export enum Store {
export interface StorePluginVersion {
name: string;
hash: string;
artifact: string | undefined | null;
}
export interface StorePlugin {
@@ -73,9 +74,11 @@ export async function installFromURL(url: string) {
}
export async function requestPluginInstall(plugin: string, selectedVer: StorePluginVersion) {
const artifactUrl =
selectedVer.artifact ?? `https://cdn.tzatzikiweeb.moe/file/steam-deck-homebrew/versions/${selectedVer.hash}.zip`;
await window.DeckyPluginLoader.callServerMethod('install_plugin', {
name: plugin,
artifact: `https://cdn.tzatzikiweeb.moe/file/steam-deck-homebrew/versions/${selectedVer.hash}.zip`,
artifact: artifactUrl,
version: selectedVer.name,
hash: selectedVer.hash,
});
+10 -12
View File
@@ -7,7 +7,6 @@ import Logger from './logger';
declare global {
interface Window {
__TABS_HOOK_INSTANCE: any;
securitystore: any;
}
}
@@ -23,7 +22,6 @@ class TabsHook extends Logger {
tabs: Tab[] = [];
private qAMRoot?: any;
private qamPatch?: Patch;
private unsubscribeSecurity?: () => void;
constructor() {
super('TabsHook');
@@ -37,7 +35,7 @@ class TabsHook extends Logger {
const tree = (document.getElementById('root') as any)._reactRootContainer._internalRoot.current;
let qAMRoot: any;
const findQAMRoot = (currentNode: any, iters: number): any => {
if (iters >= 55) {
if (iters >= 65) {
// currently 45
return null;
}
@@ -114,7 +112,6 @@ class TabsHook extends Logger {
deinit() {
this.qamPatch?.unpatch();
this.qAMRoot.return.alternate.type = this.qAMRoot.return.type;
this.unsubscribeSecurity?.();
}
add(tab: Tab) {
@@ -131,22 +128,23 @@ class TabsHook extends Logger {
let deckyTabAmount = existingTabs.reduce((prev: any, cur: any) => (cur.decky ? prev + 1 : prev), 0);
if (deckyTabAmount == this.tabs.length) {
for (let tab of existingTabs) {
if (tab?.decky) tab.panel.props.setter[0](visible);
if (tab?.decky && tab?.qAMVisibilitySetter) tab?.qAMVisibilitySetter(visible);
}
return;
}
for (const { title, icon, content, id } of this.tabs) {
existingTabs.push({
const tab: any = {
key: id,
title,
tab: icon,
decky: true,
panel: (
<QuickAccessVisibleStateProvider initial={visible} setter={[]}>
{content}
</QuickAccessVisibleStateProvider>
),
});
};
tab.panel = (
<QuickAccessVisibleStateProvider initial={visible} tab={tab}>
{content}
</QuickAccessVisibleStateProvider>
);
existingTabs.push(tab);
}
}
}
+34 -11
View File
@@ -1,4 +1,4 @@
import { Patch, ToastData, afterPatch, findInReactTree, sleep } from 'decky-frontend-lib';
import { Module, Patch, ToastData, afterPatch, findInReactTree, findModuleChild, sleep } from 'decky-frontend-lib';
import { ReactNode } from 'react';
import Toast from './components/Toast';
@@ -7,6 +7,7 @@ import Logger from './logger';
declare global {
interface Window {
__TOASTER_INSTANCE: any;
settingsStore: any;
NotificationStore: any;
}
}
@@ -16,7 +17,7 @@ class Toaster extends Logger {
// private toasterState: DeckyToasterState = new DeckyToasterState();
private node: any;
private rNode: any;
private settingsModule: any;
private audioModule: any;
private finishStartup?: () => void;
private ready: Promise<void> = new Promise((res) => (this.finishStartup = res));
private toasterPatch?: Patch;
@@ -39,11 +40,15 @@ class Toaster extends Logger {
let instance: any;
const tree = (document.getElementById('root') as any)._reactRootContainer._internalRoot.current;
const findToasterRoot = (currentNode: any, iters: number): any => {
if (iters >= 50) {
// currently 40
if (iters >= 65) {
// currently 65
return null;
}
if (currentNode?.memoizedProps?.className?.startsWith?.('toastmanager_ToastPlaceholder')) {
if (
currentNode?.memoizedProps?.className?.startsWith?.('gamepadtoasts_GamepadToastPlaceholder') ||
currentNode?.memoizedProps?.className?.startsWith?.('toastmanager_ToastPlaceholder') ||
currentNode?.memoizedProps?.className?.startsWith?.('toastmanager_ToastPopup')
) {
this.log(`Toaster root was found in ${iters} recursion cycles`);
return currentNode;
}
@@ -127,6 +132,17 @@ class Toaster extends Logger {
this.rNode.stateNode.forceUpdate();
delete this.rNode.stateNode.shouldComponentUpdate;
this.audioModule = findModuleChild((m: Module) => {
if (typeof m !== 'object') return undefined;
for (let prop in m) {
try {
if (m[prop].PlayNavSound && m[prop].RegisterCallbackOnPlaySound) return m[prop];
} catch {
return undefined;
}
}
});
this.log('Initialized');
this.finishStartup?.();
}
@@ -135,24 +151,31 @@ class Toaster extends Logger {
// toast.duration = toast.duration || 5e3;
// this.toasterState.addToast(toast);
await this.ready;
const settings = this.settingsModule?.settings;
let toastData = {
nNotificationID: window.NotificationStore.m_nNextTestNotificationID++,
rtCreated: Date.now(),
eType: 15,
eType: toast.eType || 11,
nToastDurationMS: toast.duration || (toast.duration = 5e3),
data: toast,
decky: true,
};
// @ts-ignore
toastData.data.appid = () => 0;
if (toast.sound === undefined) toast.sound = 6;
if (toast.playSound === undefined) toast.playSound = true;
if (toast.showToast === undefined) toast.showToast = true;
if (
(settings?.bDisableAllToasts && !toast.critical) ||
(settings?.bDisableToastsInGame && !toast.critical && window.NotificationStore.BIsUserInGame())
(window.settingsStore.settings.bDisableAllToasts && !toast.critical) ||
(window.settingsStore.settings.bDisableToastsInGame &&
!toast.critical &&
window.NotificationStore.BIsUserInGame())
)
return;
window.NotificationStore.m_rgNotificationToasts.push(toastData);
window.NotificationStore.DispatchNextToast();
if (toast.playSound) this.audioModule?.PlayNavSound(toast.sound);
if (toast.showToast) {
window.NotificationStore.m_rgNotificationToasts.push(toastData);
window.NotificationStore.DispatchNextToast();
}
}
deinit() {
-7
View File
@@ -1,7 +0,0 @@
export function findSP(): Window {
// old (SP as host)
if (document.title == 'SP') return window;
// new (SP as popup)
return FocusNavController.m_ActiveContext.m_rgGamepadNavigationTrees.find((x: any) => x.m_ID == 'root_1_').Root
.Element.ownerDocument.defaultView;
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"compilerOptions": {
"module": "ESNext",
"target": "ES2020",
"target": "ES2021",
"jsx": "react",
"jsxFactory": "window.SP_REACT.createElement",
"jsxFragmentFactory": "window.SP_REACT.Fragment",
@@ -18,6 +18,6 @@
"allowSyntheticDefaultImports": true,
"skipLibCheck": true
},
"include": ["src"],
"include": ["src", "index.d.ts"],
"exclude": ["node_modules"]
}
+201
View File
@@ -0,0 +1,201 @@
"""
This module exposes various constants and helpers useful for decky plugins.
* Plugin's settings and configurations should be stored under `DECKY_PLUGIN_SETTINGS_DIR`.
* Plugin's runtime data should be stored under `DECKY_PLUGIN_RUNTIME_DIR`.
* Plugin's persistent log files should be stored under `DECKY_PLUGIN_LOG_DIR`.
Avoid writing outside of `DECKY_HOME`, storing under the suggested paths is strongly recommended.
Some basic migration helpers are available: `migrate_any`, `migrate_settings`, `migrate_runtime`, `migrate_logs`.
A logging facility `logger` is available which writes to the recommended location.
"""
__version__ = '0.1.0'
import os
import subprocess
import logging
"""
Constants
"""
HOME: str = os.getenv("HOME", default="")
"""
The home directory of the effective user running the process.
Environment variable: `HOME`.
If `root` was specified in the plugin's flags it will be `/root` otherwise the user whose home decky resides in.
e.g.: `/home/deck`
"""
USER: str = os.getenv("USER", default="")
"""
The effective username running the process.
Environment variable: `USER`.
It would be `root` if `root` was specified in the plugin's flags otherwise the user whose home decky resides in.
e.g.: `deck`
"""
DECKY_VERSION: str = os.getenv("DECKY_VERSION", default="")
"""
The version of the decky loader.
Environment variable: `DECKY_VERSION`.
e.g.: `v2.5.0-pre1`
"""
DECKY_USER: str = os.getenv("DECKY_USER", default="")
"""
The user whose home decky resides in.
Environment variable: `DECKY_USER`.
e.g.: `deck`
"""
DECKY_USER_HOME: str = os.getenv("DECKY_USER_HOME", default="")
"""
The home of the user where decky resides in.
Environment variable: `DECKY_USER_HOME`.
e.g.: `/home/deck`
"""
DECKY_HOME: str = os.getenv("DECKY_HOME", default="")
"""
The root of the decky folder.
Environment variable: `DECKY_HOME`.
e.g.: `/home/deck/homebrew`
"""
DECKY_PLUGIN_SETTINGS_DIR: str = os.getenv(
"DECKY_PLUGIN_SETTINGS_DIR", default="")
"""
The recommended path in which to store configuration files (created automatically).
Environment variable: `DECKY_PLUGIN_SETTINGS_DIR`.
e.g.: `/home/deck/homebrew/settings/decky-plugin-template`
"""
DECKY_PLUGIN_RUNTIME_DIR: str = os.getenv(
"DECKY_PLUGIN_RUNTIME_DIR", default="")
"""
The recommended path in which to store runtime data (created automatically).
Environment variable: `DECKY_PLUGIN_RUNTIME_DIR`.
e.g.: `/home/deck/homebrew/data/decky-plugin-template`
"""
DECKY_PLUGIN_LOG_DIR: str = os.getenv("DECKY_PLUGIN_LOG_DIR", default="")
"""
The recommended path in which to store persistent logs (created automatically).
Environment variable: `DECKY_PLUGIN_LOG_DIR`.
e.g.: `/home/deck/homebrew/logs/decky-plugin-template`
"""
DECKY_PLUGIN_DIR: str = os.getenv("DECKY_PLUGIN_DIR", default="")
"""
The root of the plugin's directory.
Environment variable: `DECKY_PLUGIN_DIR`.
e.g.: `/home/deck/homebrew/plugins/decky-plugin-template`
"""
DECKY_PLUGIN_NAME: str = os.getenv("DECKY_PLUGIN_NAME", default="")
"""
The name of the plugin as specified in the 'plugin.json'.
Environment variable: `DECKY_PLUGIN_NAME`.
e.g.: `Example Plugin`
"""
DECKY_PLUGIN_VERSION: str = os.getenv("DECKY_PLUGIN_VERSION", default="")
"""
The version of the plugin as specified in the 'package.json'.
Environment variable: `DECKY_PLUGIN_VERSION`.
e.g.: `0.0.1`
"""
DECKY_PLUGIN_AUTHOR: str = os.getenv("DECKY_PLUGIN_AUTHOR", default="")
"""
The author of the plugin as specified in the 'plugin.json'.
Environment variable: `DECKY_PLUGIN_AUTHOR`.
e.g.: `John Doe`
"""
DECKY_PLUGIN_LOG: str = os.path.join(DECKY_PLUGIN_LOG_DIR, "plugin.log")
"""
The path to the plugin's main logfile.
Environment variable: `DECKY_PLUGIN_LOG`.
e.g.: `/home/deck/homebrew/logs/decky-plugin-template/plugin.log`
"""
"""
Migration helpers
"""
def migrate_any(target_dir: str, *files_or_directories: str) -> dict[str, str]:
"""
Migrate files and directories to a new location and remove old locations.
Specified files will be migrated to `target_dir`.
Specified directories will have their contents recursively migrated to `target_dir`.
Returns the mapping of old -> new location.
"""
file_map: dict[str, str] = {}
for f in files_or_directories:
if not os.path.exists(f):
file_map[f] = ""
continue
if os.path.isdir(f):
src_dir = f
src_file = "."
file_map[f] = target_dir
else:
src_dir = os.path.dirname(f)
src_file = os.path.basename(f)
file_map[f] = os.path.join(target_dir, src_file)
subprocess.run(["sh", "-c", "mkdir -p \"$3\"; tar -cf - -C \"$1\" \"$2\" | tar -xf - -C \"$3\" && rm -rf \"$4\"",
"_", src_dir, src_file, target_dir, f])
return file_map
def migrate_settings(*files_or_directories: str) -> dict[str, str]:
"""
Migrate files and directories relating to plugin settings to the recommended location and remove old locations.
Specified files will be migrated to `DECKY_PLUGIN_SETTINGS_DIR`.
Specified directories will have their contents recursively migrated to `DECKY_PLUGIN_SETTINGS_DIR`.
Returns the mapping of old -> new location.
"""
return migrate_any(DECKY_PLUGIN_SETTINGS_DIR, *files_or_directories)
def migrate_runtime(*files_or_directories: str) -> dict[str, str]:
"""
Migrate files and directories relating to plugin runtime data to the recommended location and remove old locations
Specified files will be migrated to `DECKY_PLUGIN_RUNTIME_DIR`.
Specified directories will have their contents recursively migrated to `DECKY_PLUGIN_RUNTIME_DIR`.
Returns the mapping of old -> new location.
"""
return migrate_any(DECKY_PLUGIN_RUNTIME_DIR, *files_or_directories)
def migrate_logs(*files_or_directories: str) -> dict[str, str]:
"""
Migrate files and directories relating to plugin logs to the recommended location and remove old locations.
Specified files will be migrated to `DECKY_PLUGIN_LOG_DIR`.
Specified directories will have their contents recursively migrated to `DECKY_PLUGIN_LOG_DIR`.
Returns the mapping of old -> new location.
"""
return migrate_any(DECKY_PLUGIN_LOG_DIR, *files_or_directories)
"""
Logging
"""
logging.basicConfig(filename=DECKY_PLUGIN_LOG,
format='[%(asctime)s][%(levelname)s]: %(message)s',
force=True)
logger: logging.Logger = logging.getLogger()
"""The main plugin logger writing to `DECKY_PLUGIN_LOG`."""
logger.setLevel(logging.INFO)
+173
View File
@@ -0,0 +1,173 @@
"""
This module exposes various constants and helpers useful for decky plugins.
* Plugin's settings and configurations should be stored under `DECKY_PLUGIN_SETTINGS_DIR`.
* Plugin's runtime data should be stored under `DECKY_PLUGIN_RUNTIME_DIR`.
* Plugin's persistent log files should be stored under `DECKY_PLUGIN_LOG_DIR`.
Avoid writing outside of `DECKY_HOME`, storing under the suggested paths is strongly recommended.
Some basic migration helpers are available: `migrate_any`, `migrate_settings`, `migrate_runtime`, `migrate_logs`.
A logging facility `logger` is available which writes to the recommended location.
"""
__version__ = '0.1.0'
import logging
"""
Constants
"""
HOME: str
"""
The home directory of the effective user running the process.
Environment variable: `HOME`.
If `root` was specified in the plugin's flags it will be `/root` otherwise the user whose home decky resides in.
e.g.: `/home/deck`
"""
USER: str
"""
The effective username running the process.
Environment variable: `USER`.
It would be `root` if `root` was specified in the plugin's flags otherwise the user whose home decky resides in.
e.g.: `deck`
"""
DECKY_VERSION: str
"""
The version of the decky loader.
Environment variable: `DECKY_VERSION`.
e.g.: `v2.5.0-pre1`
"""
DECKY_USER: str
"""
The user whose home decky resides in.
Environment variable: `DECKY_USER`.
e.g.: `deck`
"""
DECKY_USER_HOME: str
"""
The home of the user where decky resides in.
Environment variable: `DECKY_USER_HOME`.
e.g.: `/home/deck`
"""
DECKY_HOME: str
"""
The root of the decky folder.
Environment variable: `DECKY_HOME`.
e.g.: `/home/deck/homebrew`
"""
DECKY_PLUGIN_SETTINGS_DIR: str
"""
The recommended path in which to store configuration files (created automatically).
Environment variable: `DECKY_PLUGIN_SETTINGS_DIR`.
e.g.: `/home/deck/homebrew/settings/decky-plugin-template`
"""
DECKY_PLUGIN_RUNTIME_DIR: str
"""
The recommended path in which to store runtime data (created automatically).
Environment variable: `DECKY_PLUGIN_RUNTIME_DIR`.
e.g.: `/home/deck/homebrew/data/decky-plugin-template`
"""
DECKY_PLUGIN_LOG_DIR: str
"""
The recommended path in which to store persistent logs (created automatically).
Environment variable: `DECKY_PLUGIN_LOG_DIR`.
e.g.: `/home/deck/homebrew/logs/decky-plugin-template`
"""
DECKY_PLUGIN_DIR: str
"""
The root of the plugin's directory.
Environment variable: `DECKY_PLUGIN_DIR`.
e.g.: `/home/deck/homebrew/plugins/decky-plugin-template`
"""
DECKY_PLUGIN_NAME: str
"""
The name of the plugin as specified in the 'plugin.json'.
Environment variable: `DECKY_PLUGIN_NAME`.
e.g.: `Example Plugin`
"""
DECKY_PLUGIN_VERSION: str
"""
The version of the plugin as specified in the 'package.json'.
Environment variable: `DECKY_PLUGIN_VERSION`.
e.g.: `0.0.1`
"""
DECKY_PLUGIN_AUTHOR: str
"""
The author of the plugin as specified in the 'plugin.json'.
Environment variable: `DECKY_PLUGIN_AUTHOR`.
e.g.: `John Doe`
"""
DECKY_PLUGIN_LOG: str
"""
The path to the plugin's main logfile.
Environment variable: `DECKY_PLUGIN_LOG`.
e.g.: `/home/deck/homebrew/logs/decky-plugin-template/plugin.log`
"""
"""
Migration helpers
"""
def migrate_any(target_dir: str, *files_or_directories: str) -> dict[str, str]:
"""
Migrate files and directories to a new location and remove old locations.
Specified files will be migrated to `target_dir`.
Specified directories will have their contents recursively migrated to `target_dir`.
Returns the mapping of old -> new location.
"""
def migrate_settings(*files_or_directories: str) -> dict[str, str]:
"""
Migrate files and directories relating to plugin settings to the recommended location and remove old locations.
Specified files will be migrated to `DECKY_PLUGIN_SETTINGS_DIR`.
Specified directories will have their contents recursively migrated to `DECKY_PLUGIN_SETTINGS_DIR`.
Returns the mapping of old -> new location.
"""
def migrate_runtime(*files_or_directories: str) -> dict[str, str]:
"""
Migrate files and directories relating to plugin runtime data to the recommended location and remove old locations
Specified files will be migrated to `DECKY_PLUGIN_RUNTIME_DIR`.
Specified directories will have their contents recursively migrated to `DECKY_PLUGIN_RUNTIME_DIR`.
Returns the mapping of old -> new location.
"""
def migrate_logs(*files_or_directories: str) -> dict[str, str]:
"""
Migrate files and directories relating to plugin logs to the recommended location and remove old locations.
Specified files will be migrated to `DECKY_PLUGIN_LOG_DIR`.
Specified directories will have their contents recursively migrated to `DECKY_PLUGIN_LOG_DIR`.
Returns the mapping of old -> new location.
"""
"""
Logging
"""
logger: logging.Logger
"""The main plugin logger writing to `DECKY_PLUGIN_LOG`."""
+1 -1
View File
@@ -2,4 +2,4 @@ aiohttp==3.8.1
aiohttp-jinja2==1.5.0
aiohttp_cors==0.7.0
watchdog==2.1.7
certifi==2022.6.15
certifi==2022.12.7