Compare commits

...

288 Commits

Author SHA1 Message Date
AAGaming 0e5d991c8d add patch caching 2022-12-31 23:27:04 -05:00
AAGaming 0fe3282828 make that actually work lol 2022-12-31 22:52:37 -05:00
AAGaming 335d38e12b add * option for route in itempatches 2022-12-31 22:46:39 -05:00
AAGaming d762860eac missed this 2022-12-31 22:00:38 -05:00
AAGaming fdbc508fa8 Main menu and overlay patching API 2022-12-31 21:53:39 -05:00
AAGaming 81fbd0f83f Fix reloading UI on updates and restarting steam 2022-12-29 23:46:47 -05: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
TrainDoctor 478fe32527 Revert "Fix for setting json files ending up in ~/homebrew"
This reverts commit aec7063139.
2022-11-15 15:01:26 -08:00
AAGaming 50764600c8 Refactoring in preparation for WebSockets (#254)
* Fix injector race conditions

* add some more tasks

* hide useless rollup warnings

* goodbye to clientsession errors

* completely fix desktop mode switch race condition

* fix typos and TS warning in plugin error handler

* fix chown error

* start debugger if needed and not already started

* fix get_steam_resource for the like 2 legacy plugins still using it lol

* add ClientOSError to get_tabs error handling
2022-11-15 13:44:24 -08:00
TrainDoctor aec7063139 Fix for setting json files ending up in ~/homebrew 2022-11-13 17:48:46 -08:00
TrainDoctor c9ee98e0c0 Fix desktop mode loop (#253)
* fix desktop mode making injector get stuck

* Fix imports and variable references

* Get data for all messages

Co-authored-by: AAGaming <aa@mail.catvibers.me>
2022-11-13 17:36:00 -08:00
TrainDoctor 093b064a4e Inline icon propely 2022-11-13 16:44:15 -08:00
TrainDoctor 2955681975 Show plugin as error and give guidance for uninstall 2022-11-13 16:31:05 -08:00
TrainDoctor de42639726 Add errored plugins to plugin array for uninstalls 2022-11-13 16:01:06 -08:00
TrainDoctor 17742e947a Fix import revert 2022-11-08 17:05:10 -08:00
TrainDoctor 898271b33d Revert "Disable store selection until PRs actually deploy to testing"
This reverts commit d389b403b5.
2022-11-06 15:37:02 -08:00
AAGaming b44896524f Fix on steam client beta, restart steam instead of reloading tab since that is broken 2022-11-04 21:49:05 -04:00
TrainDoctor db7bb236d8 facepalm 2022-10-30 18:45:18 -07:00
TrainDoctor 5e3de747d3 Systemd service updating (#240)
* Add services and updated installer files

* Loader updates service file during update!

* Testing update branch doesn't exist lol

* Update to dfl 3.7.12

* Fix services and add working service updater

* Revert services but replace their aliases

* Fix install scripts as well

* Move leftover service files to .systemd dir

* No wonder it's not trimming the file...

* fix whitespace

* Remove unused imports

* Remove another un-used import

Co-authored-by: AAGaming <aa@mail.catvibers.me>
2022-10-30 16:37:19 -07:00
TrainDoctor d389b403b5 Disable store selection until PRs actually deploy to testing 2022-10-30 10:38:49 -07:00
TrainDoctor bace5143d2 Merge Tabs and Injection Fixes, bring back native Valve toaster (#238)
* Bring back component patch-based tabshook

* better injection point

* finally fix dumb loading error

* fix QAM injection breaking after lock

* shut up typescript

* fix lock screen focusing issues

* Bring back the Valve toaster!

* Add support for stable steamos

* fix focus bug on lock screen but actually

* oops: remove extra console log

* shut up typescript again

* better fix for lockscreen bug

* better probably

* actually fix focus issues (WTF)

Co-authored-by: AAGaming <aa@mail.catvibers.me>
2022-10-30 10:32:05 -07:00
suchmememanyskill f5fc205384 Add timeout to get tabs request, wait for network online target before booting decky (#239)
* Add timeout to tabs get request

* Wait for network interfaces to be ready before booting decky
2022-10-29 15:13:19 -07:00
TrainDoctor 4d30339c34 Add StoreSelect component 2022-10-29 15:03:21 -07:00
TrainDoctor 5996a3f88b Remove unused nightly and tweak prerelease 2022-10-29 12:43:38 -07:00
AAGaming 1b635c74b1 chore(readme): link change for store, port clarification 2022-10-29 00:55:19 -04:00
AAGaming a9bd5079de remove ver suffix 2022-10-24 21:08:35 -04:00
AAGaming c1fabe5b35 pin pyinstaller to 5.5-py3 2022-10-24 21:07:49 -04:00
AAGaming ed82f51bb7 use same python ver as deck 2022-10-24 20:55:38 -04:00
AAGaming df1524e15f nevermind dont 2022-10-24 20:51:56 -04:00
AAGaming 2edd910df3 ci: upgrade python to 3.11 2022-10-24 20:48:58 -04:00
AAGaming 1cd69097ad import uuid in injector, update DFL 2022-10-24 20:41:27 -04:00
AAGaming 84c3b039c3 preview 10/21/2022 fixes (#234)
* initial fixes: everything working except toasts and patch notes

* tabshook changes, disable toaster for now

* prettier

* oops

* implement custom toaster because I am tired of Valve's shit

also fix QAM not injecting sometimes

* remove extra logging

* add findSP, fix patch notes, fix vscode screwup

* fix patch notes

* show error when plugin frontends fail to load

* add get_tab_lambda

* add css and has_element helpers to Tab

* small modals fixup

* Don't forceUpdate QuickAccess on stable

* add routes prop used to get tabs component

* add more dev utils to DFL global
2022-10-24 16:14:56 -07:00
TrainDoctor 2e6b3834da Properly utilize image_url from Store 2022-10-23 14:00:29 -07:00
TrainDoctor 6749c78ed7 During update, download updates first before removing old plugin files (#223)
* Remove old nightly support and unused logging

* Removed legacy code + added logic to account for offline update attempts

* Update backend/browser.py

Co-authored-by: AAGaming <aa@mail.catvibers.me>

* Update backend/browser.py

Co-authored-by: AAGaming <aa@mail.catvibers.me>

* Update frontend/src/toaster.tsx

Co-authored-by: AAGaming <aa@mail.catvibers.me>

* Use str instead of String (I was tired okay...)

* Remove false logic

* look for plugins not having remote_binary in pkg

Co-authored-by: AAGaming <aa@mail.catvibers.me>
2022-10-23 13:41:12 -07:00
TrainDoctor 4ad15568cd Merge drop-legacy to main. 2022-10-23 13:04:00 -07:00
TrainDoctor 58849b3002 Reduce amount of actions taken when using updateandrun 2022-10-22 19:11:00 -07:00
TrainDoctor 6346da6fe5 Actually utilize the unload function 2022-10-22 18:44:44 -07:00
TrainDoctor af51a29055 Added unload hook for plugins. 2022-10-22 18:36:49 -07:00
TrainDoctor c546a818f1 Send version when asking for plugin list 2022-10-22 16:52:48 -07:00
TrainDoctor 0226bd2bf8 Update build.yml 2022-10-22 13:02:23 -07:00
TrainDoctor 7b16b623c8 Fix toaster initialization 2022-10-16 15:52:21 -07:00
AAGaming 6e3c05072c Developer menu (#211)
* add settings utils to use settings outside of components

* initial implementation of developer menu

*  Add support for addScriptToEvaluateOnNewDocument

* React DevTools support

* increase chance of RDT successfully injecting

* Rewrite toaster hook to not re-create the window

* remove friends focus workaround because it's fixed

* Expose various DFL utilities as DFL in dev mode

* try to fix text field focuss

* move focusable to outside field

* add onTouchEnd and onClick to focusable

* Update pnpm-lock.yaml

Co-authored-by: FinalDoom <7464170-FinalDoom@users.noreply.gitlab.com>
Co-authored-by: TrainDoctor <traindoctor@protonmail.com>
2022-10-15 20:46:42 -07:00
TrainDoctor 9b405e4bdc Fix default pre-release bump 2022-10-15 11:31:53 -07:00
AAGaming 8007dd4dac bump decky-frontend-lib 2022-10-14 23:48:56 -04:00
AAGaming 91d4e5dfc3 fix the fix 2022-10-14 23:47:57 -04:00
AAGaming c885ee600d replace deprecated set-output in CI 2022-10-14 23:46:46 -04:00
AAGaming 739b57e100 add alwaysRender 2022-10-14 23:43:45 -04:00
AAGaming 87a7361dc7 Allow B button to close active plugin and return to menu. (#218) 2022-10-14 23:33:16 -04:00
AAGaming acdea6da44 pnpm uses frozen instead of ci 2022-10-14 23:29:32 -04:00
AAGaming f23ea5b841 IDIOT 2022-10-14 23:28:37 -04:00
AAGaming d51cd4605c wtf 2022-10-14 23:27:59 -04:00
AAGaming 7d73c7aa79 setup-pnpm action is bad, do it ourself 2022-10-14 23:26:42 -04:00
AAGaming fd187a6710 forgot with 2022-10-14 23:25:13 -04:00
AAGaming 43ef9e65ea this arg format is terrible 2022-10-14 23:24:39 -04:00
AAGaming 9233ee58c6 fix ci 2022-10-14 23:22:01 -04:00
AAGaming fd59456f8b more CI cleanup 2022-10-14 23:13:36 -04:00
AAGaming 9b241101dd Upgrade NodeJS to 18 in CI 2022-10-14 23:08:27 -04:00
AAGaming bebe9428a6 fix old toast patch to not re-create window 2022-10-14 22:52:46 -04:00
AAGaming 7445f066ed Revert "Rewrite toaster hook to not re-create the window (#217)"
This reverts commit 3ac0abc82b.
2022-10-14 22:41:46 -04:00
AAGaming 6e48aefce8 fix os.path 2022-10-14 20:44:59 -04:00
AAGaming 0bc0a0dadb remove friends focus workaround 2022-10-14 20:22:26 -04:00
AAGaming 3ac0abc82b Rewrite toaster hook to not re-create the window (#217) 2022-10-14 17:09:11 -07:00
EMERALD 618abec97a README rewrite (#214)
* Improved header

* Fix header screenshot distance, improve about

* Installation section improvements

* Add mouse and keyboard steps

* Formatting all README sections

* README SVG overhaul

* Move uninstallation to installation section

* Various clarifications

* Clarification on password creation

* Add common issues section

* Add port 1337 warning
2022-10-12 15:09:09 -07:00
TrainDoctor 2518d1a0b3 Update README.md 2022-10-08 18:10:03 -07:00
TrainDoctor 010e6a22ab don't test on live folks -_- 2022-10-08 17:25:34 -07:00
TrainDoctor 134b896e01 Append "-pre" before bumping pre-release version 2022-10-08 17:17:40 -07:00
TrainDoctor 047813b965 Merge branch 'update-ci' w/ needed updates 2022-10-08 17:08:36 -07:00
AAGaming dbcb549ae2 use hasattr instead of in 2022-10-08 19:04:09 -04:00
AAGaming d689614c78 possibly fix zip permissions 2022-10-08 17:57:51 -04:00
Devon Schneider ec907627b8 Use a conditional while loop for when the scroll root is not found. (#212)
* Changed back to a while loop for grabbing the scroll root\nAs recursive function exits before it hits 30 iterations in testing.

* Changed so if recursive limit is hit, it returns null instead of attempting to continue.

* Added log messages regarding recursion limits, as well as fixed recursion tracking.

* Removed errant return remaining from scroll root check.
2022-10-08 06:35:21 -07:00
AAGaming a3809222f9 disable file watcher until after 10 seconds 2022-10-07 22:38:20 -04:00
Party Wumpus 86dc706892 README: password typing clarification (#208)
While the video does *show* the password not appearing, he doesn't mention it.
This should reduce the occasional user confusion of passwords not appearing.
2022-10-07 09:35:59 -07:00
Eddie Dover 0e409a9f96 Add plugin description to Store PluginCard (#196)
* Add plugin description to Store PluginCard

The description for plugins is available on the web but not in the store, this attempts to fix the issue.

Unfortunately, my current env is completely Windows based so I cannot test this change locally, and setting up an Arch env in WSL2 is proving to be more time consuming than such a simple PR deserves.

* Removed prefix from description

* Apply suggestions from code review

Co-authored-by: Party Wumpus <48649272+PartyWumpus@users.noreply.github.com>

* Inline style fix.

Co-authored-by: Party Wumpus <48649272+PartyWumpus@users.noreply.github.com>

* Prettier formatting fix

* Apply suggestions from code review

Co-authored-by: Party Wumpus <48649272+PartyWumpus@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: Party Wumpus <48649272+PartyWumpus@users.noreply.github.com>

Co-authored-by: Party Wumpus <48649272+PartyWumpus@users.noreply.github.com>
2022-10-05 14:24:02 -07:00
Devon Schneider d58001c323 Fixed an issue regarding the scroll root path (#201)
* Changed so searching for the scroll root node will look through siblings of children nodes.

* Added a missing await

Seem to have missed an await from when I was converting my proof-of-concept to an async function.

* Minor stylistic change

* Changed where program retries to find the scroll root.
2022-10-04 11:54:54 -07:00
TrainDoctor d727ba72f3 Fix outdated line about manual install 2022-10-02 10:56:06 -07:00
TrainDoctor fa028fa525 Update build.yml 2022-10-02 08:19:41 -07:00
TrainDoctor c947548064 Update build.yml 2022-10-02 08:11:00 -07:00
AAGaming 19d5527bdf ACTUALLY FIX the friends focus workaround 2022-10-02 10:29:33 -04:00
AAGaming ef51b96f08 fix styling on links in markdown 2022-10-02 10:14:51 -04:00
AAGaming 617916e8e5 actually fix focus issue on startup 2022-10-01 23:10:25 -04:00
TrainDoctor 6c4a4d0a44 Update install_release.sh 2022-10-01 19:04:05 -07:00
TrainDoctor bedcb0fb71 Update README.md 2022-10-01 18:58:59 -07:00
AAGaming 2461f52ca7 change console.log to styled log 2022-10-01 21:24:10 -04:00
AAGaming 3c00eb8cf4 fix friends menu focusing itself 2022-10-01 21:22:30 -04:00
TrainDoctor 21e1d8504a Update README.md 2022-10-01 16:17:40 -07:00
Party Wumpus ba93c4add2 Change 'PluginLoader' to 'Decky Loader' in readme (#190)
* (readme) Make store link go to beta.deckbrew + Change wording for clarification

* further change

* change line 26 to say plugin menu

* Change all 'PluginLoader' to 'Decky Loader' in readme

Seems like a reasonable change as Decky was rebranded a while ago
2022-10-01 14:41:09 -07:00
TrainDoctor 61fea41c8a Update package.json 2022-09-30 21:40:51 -07:00
TrainDoctor e40d3e4db5 Removal of legacy link 2022-09-30 21:31:19 -07:00
Lukas Senionis bbad6bf2be fix(pluginview): align icon and plugin name (#185) 2022-09-26 21:51:39 -07:00
Party Wumpus 4e04455163 (readme) Make store link go to beta.deckbrew + Change wording (#184)
* (readme) Make store link go to beta.deckbrew + Change wording for clarification

* further change

* change line 26 to say plugin menu
2022-09-25 10:04:50 -07:00
Dan Burkhardt 314292b042 updated installation steps to reflect confirmation and system reboot step (#180)
Co-authored-by: TrainDoctor <traindoctor@protonmail.com>
2022-09-21 15:05:18 -07:00
Aamir Tahir a264f36966 Add note about opening terminal to README (#176)
Quick note about how to open the terminal using the pre-installed Konsole app for users that are either new to linux or Steam Deck.
2022-09-21 14:56:45 -07:00
AAGaming 60c8c5db42 check for plugin updates after installing a plugin 2022-09-19 21:13:06 -04:00
Trent Callan 852c52c59a Grab Plugin path from find_plugin_folder plugin name is not always folder anme (#178) 2022-09-18 22:13:45 -07:00
Trent Callan 3136ad72ed Download Remote Binaries during Store Install (#177)
* Download Remote Binaries during store install.
Fix Manual Install Modal Type

* Use Unix Line endings. (Thanks WSL)

* Fix Merge Commit with main branch.
2022-09-18 17:20:19 -07:00
AAGaming 3700dd7437 fix plugin updating 2022-09-18 15:44:16 -04:00
AAGaming c6d48389c9 bump lib 2022-09-18 15:44:01 -04:00
AAGaming 490fc18008 enable debug logs in dev 2022-09-18 15:43:26 -04:00
AAGaming 797c7ea3b0 hopefully actually fix toaster 2022-09-18 15:03:59 -04:00
AAGaming 0f06bc1ef0 Updater UI tweaks, Markdown tweaks, bump lib 2022-09-18 14:53:36 -04:00
AAGaming c774451ff4 don't crash when plugin backends do 2022-09-18 14:51:17 -04:00
AAGaming 62a5bdbbb0 add more URLs to bypass authentication 2022-09-18 14:49:56 -04:00
AAGaming 7716c73014 fix plugin loading after install, move updater reloads to loader 2022-09-18 14:49:32 -04:00
AAGaming 8829adc5b6 update tasks 2022-09-18 14:48:08 -04:00
AAGaming 62bd3e76bd small changes making breakpoint debugging easier 2022-09-18 11:07:30 -04:00
AAGaming 9867d7bea0 Possibly actually fix the toasts issue 2022-09-18 10:17:39 -04:00
AAGaming c4d6731401 fix updater for new installs, fix file picker patch, fix scrolling on patch notes, fix tasks dir 2022-09-17 23:23:51 -04:00
AAGaming fded2fa8bf fix plugin install modal 2022-09-17 16:02:03 -04:00
TrainDoctor 90c523ec45 Update updater.py 2022-09-17 09:26:24 -07:00
AAGaming c5ccb4dfb8 fix TS errors in Toast 2022-09-17 11:36:53 -04:00
AAGaming 8b1925bc53 fix spacing [skip ci] 2022-09-17 11:35:31 -04:00
AAGaming a8c7c2f18f fix asyncio error on get_branch 2022-09-17 11:35:05 -04:00
AAGaming 463258febb wait for toaster ready, hopefully fix file browser patch 2022-09-16 18:49:35 -04:00
TrainDoctor 304fc0f94c Update build.yml 2022-09-11 15:40:19 -07:00
AAGaming b5b041fdee add file picker, add library file picker patch, bump lib, logger tweaks 2022-09-09 16:25:52 -04:00
AAGaming 9d980618a7 spice up js-side logs from store and branch select 2022-09-08 20:19:54 -04:00
Nik 6dad3f81e8 Updated screenshot 2022-09-08 23:22:47 +02:00
TrainDoctor adc1a792fb Whoopsie 2022-09-05 20:13:08 -07:00
TrainDoctor 6347ad0856 Added handling for release to pre-release, pre-rel to rel and rel to rel 2022-09-05 20:03:09 -07:00
TrainDoctor 1377d83023 Better handling for release to new pre-release 2022-09-05 19:37:33 -07:00
TrainDoctor 43d36d2b35 temp 2022-09-05 19:32:27 -07:00
AAGaming 591c58330c fix(toaster): fix toasts rendering multiple times 2022-09-05 14:28:52 -04:00
AAGaming 501145a210 fix(toasts): handle toasts ingame properly without useComposition hack 2022-09-05 11:24:04 -04:00
TrainDoctor a3659ba425 Oops I need that 2022-09-04 21:14:38 -07:00
TrainDoctor d1887870f5 return default value for out to "" 2022-09-04 21:03:45 -07:00
TrainDoctor 1892403044 better equivalency checking 2022-09-04 20:56:38 -07:00
TrainDoctor f5a1837227 OUT 2022-09-04 20:51:16 -07:00
TrainDoctor 97f95705f8 Fix pre-release, none 2022-09-04 20:39:57 -07:00
TrainDoctor 7c99af9a9a Add more logging and change release bump logic 2022-09-04 20:25:44 -07:00
TrainDoctor b35bd056d5 Work on bump logic 2022-09-04 20:11:06 -07:00
TrainDoctor d2da85460d Update CI to accommodate custom version bumps 2022-09-04 20:04:11 -07:00
AAGaming 843e03b42c fix(toaster): allow toasts to show ingame 2022-09-04 22:14:35 -04:00
TrainDoctor 5f469bfb16 Merge ci-for-auto-update to main 2022-09-04 18:18:51 -07:00
AAGaming acaf6c72e4 Update decky-frontend-lib and refactor patches 2022-09-04 13:33:42 -04:00
TrainDoctor eb439574be Addition of proper branch slection (#168)
* This is a bit better, but branch selection still isn't working -_-

* I'm the king of oversight

* Selecting different branch checks for updates

* Stable doesn't detect old versions, which indicates it doesn't work

* Start adding deckyState for plugin updating

* Few tweaks

* Disable nightly selection

* Update decky-frontend-lib and move useSetting set setting to async
2022-09-01 13:46:49 -07:00
TrainDoctor 16a6e9b6a9 Give SettingsManager a default directory 2022-08-31 12:20:31 -07:00
Philipp Richter 6f84cf94b5 Fix LimitOverrunError when a lot of data is passed to the backend (fixes #158) (#161)
* Fix LimitOverrunError when a lot of data is passed to the backend

* Raise socket's buffer limit to something more sensible
2022-08-31 10:46:40 -07:00
TrainDoctor 7c06db5ece Moved traceback to higher log level for cleaner logs 2022-08-29 15:18:18 -07:00
TrainDoctor aeb2decfc1 Fix plugin uninstalling 2022-08-29 14:41:10 -07:00
botato b7d7ca04e1 Refractor plugin backend (#111)
* refractor uninstall plugin backend

* refractor plugin installation method

* Change formatting in browser.py

* Manually format main.py

* Manually format utilities.py

* remove inconsistency

* remove unnecessary linebreaks

* lol what

* last minute pythoning

* Fix async missing

* lint

* more refractor

* await forgotten

* fix: menu not disappearing after first click

* lint

* bug: fix double click on uninstall

* depricate request installs

* basic patch notes viewer, lazy-load settings and store, build frontend as esmodule, add lazy-loaded react-markdown, backend changes to accomodate ESModule frontend

* refractor uninstall plugin backend

* Change formatting in browser.py

* Manually format main.py

* Manually format utilities.py

* remove unnecessary linebreaks

* lol what

* last minute pythoning

* Fix async missing

* rebase onto main

* fix error, fix React crash if patch notes are opened before remote version info is loaded

Co-authored-by: TrainDoctor <traindoctor@protonmail.com>
Co-authored-by: AAGaming <aa@mail.catvibers.me>
2022-08-26 21:01:23 -07:00
AAGaming d4d1c2bbab basic patch notes viewer, lazy-load settings and store, build frontend as esmodule, add lazy-loaded react-markdown, backend changes to accomodate ESModule frontend 2022-08-26 01:18:28 -04:00
TrainDoctor effc4ab0f5 Hide branch select until it's working 2022-08-24 21:18:38 -07:00
AAGaming 79db0c779d Settings API for loader, preview branch select 2022-08-24 23:51:20 -04:00
AAGaming fe2b6b0283 fix plugins menu scrolling, shut up typecript 2022-08-24 20:49:39 -04:00
Chris Simons b9a87cd785 set gid along with uid (#152) 2022-08-24 17:22:07 -07:00
AAGaming 98e9ce881f move DeckyUpdater to effect 2022-08-24 20:03:29 -04:00
TrainDoctor e49bdd9c05 Move conditional check to correct location 2022-08-24 16:56:20 -07:00
TrainDoctor d0fd2ac674 Move to using deckyState 2022-08-24 13:47:21 -07:00
TrainDoctor de1c89af21 Fix updater being corrupted by multiple attempts to download 2022-08-23 15:23:23 -07:00
AAGaming 8b3f569a09 Add plugin updater, notification badge, fixes 2022-08-21 16:41:25 -04:00
Collin Diekvoss 1930400032 Better wrapping of plugin tags (#150) 2022-08-20 21:40:57 -04:00
Sefa Eyeoglu 43dee863cd Add CEF Remote Debugging toggle (#129)
* feat: add CEF Remote Debugging toggle

* feat: disable remote debugger on startup

* refactor: stop debugger instead of disable

* feat: add option to allow remote debugging by default

Co-authored-by: TrainDoctor <traindoctor@protonmail.com>
2022-08-18 14:50:59 -07:00
TrainDoctor 55a7682663 fix ButtonItem shim applying when not needed 2022-08-17 17:32:50 -07:00
TrainDoctor d05e8d36b4 Update to latest decky-frontend-lib 2022-08-17 17:28:30 -07:00
AAGaming 0018b8e957 bump lib and add temporary shims for webpack v5 2022-08-17 20:03:45 -04:00
TrainDoctor 59038f65ac Fix log spam from injection related errors 2022-08-17 12:57:58 -07:00
AAGaming 5960c11d60 add class names to PluginCard for theming 2022-08-17 15:27:22 -04:00
Sefa Eyeoglu 8d065eab1f Add Plugin Reload Button to Settings (#128)
* feat: add reload button to plugin list

Signed-off-by: Sefa Eyeoglu <contact@scrumplex.net>

* refactor: move plugin actions into context menu

Signed-off-by: Sefa Eyeoglu <contact@scrumplex.net>
2022-08-16 16:51:39 -07:00
AAGaming 3b1b6d28d6 add some classes for nicer scrolling, update lib 2022-08-15 13:22:38 -04:00
AAGaming 0a735886c9 fix toasts breaking sometimes 2022-08-14 21:59:55 -04:00
AAGaming c9430f5be4 less stupid method 2022-08-14 13:17:39 -04:00
AAGaming a4e2237fc0 fix loader not re-injecting on restart 2022-08-14 12:51:07 -04:00
AAGaming 85d0398e62 shut typescript up 2022-08-14 00:02:01 -04:00
AAGaming 30a538e85e FINALLY fix the multiple injections bug 2022-08-13 23:58:57 -04:00
AAGaming 84a19203c5 fix injecting twice 2022-08-13 11:57:52 -04:00
AAGaming 99cda2907d fix TS errors 2022-08-12 21:02:11 -04:00
AAGaming a38582d158 Fix toaster deinit error 2022-08-12 16:49:28 -04:00
TrainDoctor 9556994e14 fix empty settings and store screens after reboot 2022-08-12 11:45:29 -07:00
OMGDuke dee2cfa47b remove console.log that was causing lots of log spam (#138) 2022-08-12 09:54:57 -04:00
TrainDoctor 463403be23 Update build.yml 2022-08-11 20:37:46 -07:00
TrainDoctor b68eaca55d Updater should now find all version tags 2022-08-11 20:12:17 -07:00
AAGaming 114c54c9b0 Fix route unpatching 2022-08-11 20:34:55 -04:00
TrainDoctor 47e0661773 Add releases back to the mix 2022-08-11 17:10:37 -07:00
TrainDoctor 6c48dfe7f6 Actually send the proper variable out 2022-08-11 16:54:13 -07:00
TrainDoctor ed0ae7c9e2 Removed un-needed trimming 2022-08-11 16:48:50 -07:00
TrainDoctor ea265ae6df Corrected dummy tag, added echoing 2022-08-11 16:18:21 -07:00
TrainDoctor 860caf440b Add semver tool, temporarily disable triggered pre-releases 2022-08-11 16:10:00 -07:00
TrainDoctor 64040879f5 Update to latest version of decky-frontend-lib 2022-08-10 15:48:48 -07:00
AAGaming e92073162a oops: remove test log 2022-08-10 16:34:53 -04:00
AAGaming 67426af3ef Add api for showing toast notifications 2022-08-09 21:52:03 -04:00
Sefa Eyeoglu 0dbdb4a143 fix: don't pass unzip job to event loop (#136)
For some reason this broke installation of plugins when another specific
plugin was present (vibrantDeck)
2022-08-09 12:06:33 -07:00
TrainDoctor c9e9c45b37 Standardize logging in browser.py 2022-08-08 13:06:04 -07:00
TrainDoctor 6bc8a4fb1d Add missing import 2022-08-08 12:38:35 -07:00
Derek J. Clark 20094c5f75 Use Environment Variables (#123)
Uses environment variables instead of hard coding the "deck" user/group.
This adds support for systems other than the steam deck that are using the DeckUI.

* Use Environment Variables

* Use method to get USER from a systemd root process

* Fix imports. Add get_user and get_user_group methods in helpers.py. Removed duplicated code

* Add separate setters/getters for user vars. Ensure sleep prevents race condition of user setter in while loop
2022-08-08 11:32:14 -07:00
AAGaming 198591dbd7 whoops don't need it here 2022-08-05 21:18:19 -04:00
AAGaming f21d34506d Implement CSRF protection 2022-08-05 21:16:29 -04:00
AAGaming ab6ec98160 API for patching existing routes, lower power use 2022-08-02 18:54:55 -04:00
Beebles f1e809781a forgot one update (#125) 2022-07-30 19:42:16 -07:00
Beebles 789058b72f Readme.md references incorrect github repo (#124) 2022-07-30 18:40:44 -07:00
TrainDoctor 4a68b1430d Update README.md 2022-07-28 13:50:03 -07:00
TrainDoctor 66c4a7e16e Update README.md 2022-07-25 17:29:56 -07:00
TrainDoctor b929b2dddf Update README.md 2022-07-25 17:08:10 -07:00
TrainDoctor fb0b703438 Fix unintended question mark in "Installing" modal 2022-07-25 16:07:16 -07:00
AAGaming afb2c7c0ed Better install process UX, fix reinstalling 2022-07-25 17:13:50 -04:00
AAGaming 52dded85ed quick fix for routes refreshing constantly 2022-07-24 11:51:42 -04:00
AAGaming 2004bdebbf fix calibration menu in controller settings 2022-07-24 11:37:38 -04:00
AAGaming c9bf8d357e use fstring 2022-07-21 22:03:11 -04:00
AAGaming 09eee761a5 change log to debug 2022-07-21 22:02:47 -04:00
AAGaming 20f43b2fd4 fix plugin uninstalling 2022-07-21 22:02:13 -04:00
AAGaming e6dd1c29d8 remove modal box shadow 2022-07-17 16:42:24 -04:00
AAGaming 6e88c7c9ac Show warning when installing legacy plugins 2022-07-17 16:09:42 -04:00
AAGaming f015e00561 more updater fixes 2022-07-15 12:57:51 -04:00
AAGaming e07827cdb5 catch rm errors 2022-07-15 12:36:16 -04:00
AAGaming 103d43e7c9 fix updater 2022-07-15 12:31:30 -04:00
AAGaming 23b7df0ce2 wait 30s before first update check 2022-07-15 12:25:27 -04:00
AAGaming a5671e19ce fix ci AGAIN 2022-07-15 12:20:05 -04:00
AAGaming f2fbd399fe allow users to manually check for updates 2022-07-15 12:16:57 -04:00
AAGaming 28b91963a9 fix ci part 4
fix ci part 5

fix ci part 6

fix ci part 7

fix ci part 8

fix ci part 9

fix ci part 10

fix ci part 11
2022-07-15 11:55:39 -04:00
AAGaming ce2268370f this slipped through the linter 2022-07-15 10:53:41 -04:00
AAGaming 59462041b1 fix ci part 3: shopt edition 2022-07-15 10:52:08 -04:00
AAGaming d4d32c8d55 fix ci part 2 2022-07-15 10:45:23 -04:00
AAGaming e600aeccc7 fix ci startup failure 2022-07-15 10:38:03 -04:00
AAGaming 162d1b561b fix lockup in _open_socket_if_not_exists, probably fix ci prereleases 2022-07-15 10:34:47 -04:00
Brian Choy ba824fc921 Fix jq errors in prerelease script (#118)
* Fix jq errors in prerelease script

* Use multivariable output, add back RELEASE var
2022-07-15 09:12:07 -04:00
AAGaming 8c8cf180fa Updater for decky-loader (#117)
* Add an updater in settings for decky-loader

* add chmod

* remove junk comments
2022-07-14 22:51:55 -04:00
AAGaming 05d11cfff0 fix get_tabs oopsie 2022-07-13 23:24:29 -04:00
botato 3c24b37247 change ci again (#116) 2022-07-13 21:19:19 -04:00
AAGaming dbb4bc5ab4 another CI fix from botato 2022-07-12 12:11:42 -04:00
botato b00b04ceeb Fix action not detecting prerelease 2022-07-11 17:41:11 -04:00
botato 470f16adda CI revamp (#110)
* ci: automatically make releases, ...

- option to run manually (for full-fledged releases)
- cron schedule for pre-releases (every day at 1 pm UTC)
- semantic versioning
- Automatically generated release description

* formatting

* more formatting .-.

* Tweak according to latest release
2022-07-11 09:13:56 +02:00
botato 76424174ed Use call instead of Popen (#113) 2022-07-11 08:56:36 +02:00
AAGaming b618fe1e97 bump lib 2022-07-07 00:03:56 -04:00
AAGaming 45949e8456 support non-ui plugins 2022-07-07 00:03:20 -04:00
TrainDoctor e3a965329d Update install_prerelease.sh 2022-07-04 08:27:44 -07:00
TrainDoctor 6ee41578ea Update plugin-loader.tsx 2022-07-03 16:56:35 -07:00
TrainDoctor 9404215399 Make legacy tag text readable 2022-07-03 16:18:07 -07:00
AAGaming b8bf150a74 fix legacy coloring 2022-07-03 19:12:10 -04:00
AAGaming add3f77c1a colorize legacy tag 2022-07-03 18:48:58 -04:00
AAGaming 6c42661f86 hack: temp hide example plugin 2022-07-03 17:37:39 -04:00
TrainDoctor 2b3c219e38 * Async onOK
* await confirm_plugin_install

* wait until we've exited store to re-open QAM
2022-07-03 14:28:48 -07:00
TrainDoctor 8eb89da373 Update README.md 2022-07-03 13:30:58 -07:00
TrainDoctor ace9f61e50 Redirect to QAM after installing a plugin, QOL. 2022-07-03 12:52:22 -07:00
WerWolv baa02c129f Fixed plugin installation ssl verification issue (#101)
* Added cert location debugging

* Install certifi

* Try adding manual cacert in install request

* Properly use ssl

* More efficiently load ssl certificate
2022-07-03 08:29:46 +02:00
TrainDoctor 1e6b3edbf2 Merge remote-tracking branch 'origin/main' 2022-07-02 23:14:51 -07:00
botato 085aacea06 Use deckyState in uninstall menu (fixes #98) (#100) 2022-07-02 22:14:43 -04:00
TrainDoctor 675e667a9e Catch uninstall plugin 2022-07-02 17:09:21 -07:00
TrainDoctor 58b2c4208d Remove bugged rename invocation 2022-07-02 16:37:23 -07:00
TrainDoctor c2693869a7 Fix debug logging 2022-07-02 16:04:09 -07:00
TrainDoctor 683c51ceac Properly await uninstall 2022-07-02 15:59:15 -07:00
TrainDoctor 630e8b7213 Update prerelease script 2022-07-02 15:37:20 -07:00
TrainDoctor 246b31794a Update workflow 2022-07-02 14:55:27 -07:00
TrainDoctor b7d57de378 Add pre-release install script 2022-07-02 14:42:41 -07:00
TrainDoctor ee8aa98446 Update README.md, password is needed (#70)
(cherry picked from commit 1199c080bc)
Added some context and changed wording on uninstall.
2022-07-02 12:41:25 -07:00
TrainDoctor 557a00aed7 Update README.md 2022-07-01 17:15:32 -07:00
botato 4daf028e7a Uninstall functionality (#97)
* feat: POC uninstallation feature

* Fixes, placeholder

* bugfix: wrong function call

* add oncancel and change function called

* clean up plugin uninstall code

* bugfix, uninstall in store

* Limit scope of feature branch

* feat: PluginLoader.unloadPlugin

* problematic logs
2022-07-01 16:43:17 -07:00
AAGaming 934a50f683 fix legacy plugin duplication 2022-07-01 11:50:08 -04:00
TrainDoctor aa4f1b1e87 pnpm update 2022-06-30 15:15:15 -07:00
AAGaming 67495d30d6 fix packager 2022-06-30 16:48:49 -04:00
AAGaming d72f364a8d backwards-compatible plugin store, legacy plugin library 2022-06-30 16:04:29 -04:00
TrainDoctor da0f7dd337 Tone down hash missing warning. 2022-06-29 12:23:11 -07:00
TrainDoctor 518b01f571 Installing from plugin store now works as intended 2022-06-29 11:46:06 -07:00
AAGaming 3f2a2bbc04 fix installing plugins 2022-06-29 12:25:50 -04:00
AAGaming 79e8af8be6 update store for new format, awaiting cors to test 2022-06-29 12:17:25 -04:00
AAGaming 18d444e8fc fix tab type, bump lib for tree shaking 2022-06-29 11:57:59 -04:00
hulkrelax abc5ce5382 remove body property in args (#91) 2022-06-28 21:12:55 -04:00
AAGaming 9619c52720 add settings page with install from URL option 2022-06-22 23:22:27 -04:00
TrainDoctor 80b223180e Remove old info and redirect to wiki for in-development info 2022-06-21 14:32:02 -07:00
TrainDoctor 1d5d14b492 Added remote launch option 2022-06-21 13:49:12 -07:00
TrainDoctor ce23534ccc Remove argument parity between scripts, not sustainable solution 2022-06-21 12:36:43 -07:00
AAGaming e6e74d8e9d Don't allow overriding name 2022-06-21 09:52:54 -04:00
TrainDoctor 6289578f68 Update pnpm-lock.yaml 2022-06-20 20:40:50 -07:00
AAGaming e7c44ee202 Replace tabs hook, fix panels, bump lib 2022-06-20 23:37:52 -04:00
TrainDoctor 39f6a7688d Converted install script to pnpm 2022-06-20 20:24:44 -07:00
TrainDoctor 47ca3ece4a Added python depdency install, fixed use-case phrasing 2022-06-20 18:56:22 -07:00
Jonas Dellinger 3e250dd180 Fix importPlugin queue 2022-06-20 15:54:31 +02:00
Jonas Dellinger 711af3bca3 Fix onDismount 2022-06-20 15:34:08 +02:00
Jonas Dellinger 9a6930571c Fix onDismount 2022-06-20 15:29:40 +02:00
Jonas Dellinger d9dd09c69b Revert "fix onDismount"
This reverts commit daca482ed8.
2022-06-20 15:28:30 +02:00
AAGaming daca482ed8 fix onDismount 2022-06-19 18:56:02 -04:00
AAGaming 99b4b939bd Implement React-based plugin store (#81)
Co-authored-by: TrainDoctor <11465594+TrainDoctor@users.noreply.github.com>
Co-authored-by: WerWolv <werwolv98@gmail.com>
2022-06-17 18:43:53 -04:00
98 changed files with 8582 additions and 4614 deletions
+36
View File
@@ -0,0 +1,36 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
**Description**
[A clear and concise description of what the bug is.]
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
[A clear and concise description of what you expected to happen.]
**Screenshots**
[If applicable, add screenshots to help explain your problem.]
**Version information**
- SteamOS Version: ``[Run ``uname -a`` and place the output here. Leave the single quotations outside.]``
- Selected Update Channel: [Stable, Beta or Preview.]
**Logs**
[Please reboot your deck (if possible) when attempting to recreate the issue, then run
``cd ~ && journalctl -b0 -u plugin_loader.service > backendlog.txt``. This will save the log file to ``~`` aka ``/home/deck``. Please upload the file here in place of this textblock.]
**Additional context**
Have you modified the read-only filesystem at any point?
[Yes or No.]
+234 -34
View File
@@ -2,52 +2,252 @@ name: Builder
on:
push:
branches: [ "*" ]
pull_request:
branches: [ "*" ]
# schedule:
# - cron: '0 13 * * *' # run at 1 PM UTC
workflow_dispatch:
inputs:
release:
type: choice
description: Release the asset
default: 'none'
options:
- none
- prerelease
- release
bump:
type: choice
description: Semver to bump
default: 'none'
options:
- none
- patch
- minor
- major
permissions:
contents: read
contents: write
jobs:
build:
name: Build PluginLoader
runs-on: ubuntu-20.04
name: Packager
steps:
- name: Print input
run : |
echo "release: ${{ github.event.inputs.release }}\n"
echo "bump: ${{ github.event.inputs.bump }}\n"
- name: Checkout 🧰
uses: actions/checkout@v3
- name: Set up NodeJS 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
[ -f requirements.txt ] && 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 ./backend/*.py
- name: Upload package artifact ⬆️
if: ${{ !env.ACT }}
uses: actions/upload-artifact@v3
with:
name: PluginLoader
path: ./dist/PluginLoader
- name: Download package artifact locally
if: ${{ env.ACT }}
uses: actions/upload-artifact@v3
with:
path: ./dist/PluginLoader
release:
name: Release stable version of the package
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release == 'release' }}
needs: build
runs-on: ubuntu-latest
steps:
- name: 🧰 Checkout
- name: Checkout 🧰
uses: actions/checkout@v3
- name: 💎 Set up NodeJS 17
uses: actions/setup-node@v3
- name: Install semver-tool asdf
uses: asdf-vm/actions/install@v1
with:
node-version: 17
- name: 🐍 Set up Python 3.10
uses: actions/setup-python@v3
with:
python-version: "3.10"
- name: ⬇️ Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install pyinstaller
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
tool_versions: |
semver 3.3.0
- name: ⬇️ Install NodeJS dependencies
run: |
cd frontend
npm i
npm run build
- name: 🛠️ Build
run: |
pyinstaller --noconfirm --onefile --name "Decky" --add-data ./backend/static:/static ./backend/*.py
- name: ⬆️ Upload package
uses: actions/upload-artifact@v2
- name: Fetch package artifact ⬇️
uses: actions/download-artifact@v3
if: ${{ !env.ACT }}
with:
name: Plugin Loader
path: |
./dist/*
name: PluginLoader
path: dist
- name: Get latest release
uses: rez0n/actions-github-release@main
id: latest_release
env:
token: ${{ secrets.GITHUB_TOKEN }}
repository: "SteamDeckHomebrew/decky-loader"
type: "nodraft"
- name: Prepare tag ⚙️
id: ready_tag
run: |
export VERSION=${{ steps.latest_release.outputs.release }}
echo "VERS: $VERSION"
OUT="notsemver"
if [[ "$VERSION" =~ "-pre" ]]; then
printf "is prerelease, bumping to release\n"
OUT=$(semver bump release "$VERSION")
printf "OUT: ${OUT}\n"\
printf "bumping by selected type.\n"
if [[ "${{github.event.inputs.bump}}" != "none" ]]; then
OUT=$(semver bump ${{github.event.inputs.bump}} "$OUT")
printf "OUT: ${OUT}\n"
else
printf "no type selected, defaulting to patch.\n"
OUT=$(semver bump patch "$OUT")
printf "OUT: ${OUT}\n"
fi
elif [[ ! "$VERSION" =~ "-pre" ]]; then
printf "previous tag is a release, bumping by selected type.\n"
if [[ "${{github.event.inputs.bump}}" != "none" ]]; then
OUT=$(semver bump ${{github.event.inputs.bump}} "$VERSION")
printf "OUT: ${OUT}\n"
else
printf "previous tag is a release, but no bump selected. Defaulting to a patch bump.\n"
OUT=$(semver bump patch "$VERSION")
printf "OUT: ${OUT}\n"
fi
fi
echo "vOUT: v$OUT"
echo tag_name=v$OUT >> $GITHUB_OUTPUT
- name: Push tag 📤
uses: rickstaa/action-create-tag@v1.3.2
if: ${{ steps.ready_tag.outputs.tag_name && github.event_name == 'workflow_dispatch' && !env.ACT }}
with:
tag: ${{ steps.ready_tag.outputs.tag_name }}
message: Pre-release ${{ steps.ready_tag.outputs.tag_name }}
- name: Release 📦
uses: softprops/action-gh-release@v1
if: ${{ github.event_name == 'workflow_dispatch' && !env.ACT }}
with:
name: Prerelease ${{ steps.ready_tag.outputs.tag_name }}
tag_name: ${{ steps.ready_tag.outputs.tag_name }}
files: ./dist/PluginLoader
prerelease: false
generate_release_notes: true
prerelease:
name: Release the pre-release version of the package
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release == 'prerelease' }}
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout 🧰
uses: actions/checkout@v3
- name: Install semver-tool asdf
uses: asdf-vm/actions/install@v1
with:
tool_versions: |
semver 3.3.0
- name: Fetch package artifact ⬇️
uses: actions/download-artifact@v3
if: ${{ !env.ACT }}
with:
name: PluginLoader
path: dist
- name: Get latest release
uses: rez0n/actions-github-release@main
id: latest_release
env:
token: ${{ secrets.GITHUB_TOKEN }}
repository: "SteamDeckHomebrew/decky-loader"
type: "nodraft"
- name: Prepare tag ⚙️
id: ready_tag
run: |
export VERSION=${{ steps.latest_release.outputs.release }}
echo "VERS: $VERSION"
OUT=""
if [[ ! "$VERSION" =~ "-pre" ]]; then
printf "pre-release from release, bumping by selected type and prerel\n"
if [[ ! ${{ github.event.inputs.bump }} == "none" ]]; then
OUT=$(semver bump ${{github.event.inputs.bump}} "$VERSION")
printf "OUT: ${OUT}\n"
else
printf "type not selected, defaulting to patch\n"
OUT=$(semver bump patch "$VERSION")
printf "OUT: ${OUT}\n"
fi
OUT="$OUT-pre"
OUT=$(semver bump prerel "$OUT")
printf "OUT: ${OUT}\n"
elif [[ "$VERSION" =~ "-pre" ]]; then
printf "pre-release to pre-release, bumping by selected type and or prerel version\n"
if [[ ! ${{ github.event.inputs.bump }} == "none" ]]; then
OUT=$(semver bump ${{github.event.inputs.bump}} "$VERSION")
printf "OUT: ${OUT}\n"
OUT="$OUT-pre"
printf "OUT: ${OUT}\n"
printf "bumping prerel\n"
OUT=$(semver bump prerel "$OUT")
printf "OUT: ${OUT}\n"
else
printf "type not selected, defaulting to new pre-release only\n"
printf "bumping prerel\n"
OUT=$(semver bump prerel "$VERSION")
printf "OUT: ${OUT}\n"
fi
fi
printf "vOUT: v${OUT}\n"
echo tag_name=v$OUT >> $GITHUB_OUTPUT
- name: Push tag 📤
uses: rickstaa/action-create-tag@v1.3.2
if: ${{ steps.ready_tag.outputs.tag_name && github.event_name == 'workflow_dispatch' && !env.ACT }}
with:
tag: ${{ steps.ready_tag.outputs.tag_name }}
message: Pre-release ${{ steps.ready_tag.outputs.tag_name }}
- name: Release 📦
uses: softprops/action-gh-release@v1
if: ${{ github.event_name == 'workflow_dispatch' && !env.ACT }}
with:
name: Prerelease ${{ steps.ready_tag.outputs.tag_name }}
tag_name: ${{ steps.ready_tag.outputs.tag_name }}
files: ./dist/PluginLoader
prerelease: true
generate_release_notes: true
+8 -2
View File
@@ -154,5 +154,11 @@ cython_debug/
# static files are built
backend/static
# pnpm lockfile
frontend/pnpm-lock.yaml
# ignore settings.json
# prevents leaking login details
.vscode/settings.json
# plugins folder for local launches
plugins/*
act/.directory
act/artifacts/*
Vendored Executable
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
SCRIPT_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]:-$0}"; )" &> /dev/null && pwd 2> /dev/null; )";
# printf "${SCRIPT_DIR}\n"
# printf "$(dirname $0)\n"
if ! [[ -e "${SCRIPT_DIR}/settings.json" ]]; then
printf '.vscode/settings.json does not exist. Creating it with default settings. Exiting afterwards. Run your task again.\n\n'
cp "${SCRIPT_DIR}/defsettings.json" "${SCRIPT_DIR}/settings.json"
exit 1
else
printf '.vscode/settings.json does exist. Congrats.\n'
printf 'Make sure to change settings.json to match your deck.\n'
fi
+7
View File
@@ -0,0 +1,7 @@
{
"deckip" : "0.0.0.0",
"deckport" : "22",
"deckpass" : "ssap",
"deckkey" : "-i ${env:HOME}/.ssh/id_rsa",
"deckdir" : "/home/deck"
}
+12 -3
View File
@@ -2,16 +2,25 @@
"version": "0.2.0",
"configurations": [
{
"name": "Debug",
"name": "Run (Remote)",
"type": "python",
"request": "launch",
"console": "integratedTerminal",
"preLaunchTask": "remoterun",
"cwd": "",
"program": "",
},
{
"name": "Run (Local)",
"type": "python",
"request": "launch",
"program": "${workspaceFolder}/backend/main.py",
"cwd": "${workspaceFolder}/backend",
"console": "integratedTerminal",
"env": {
"PLUGIN_PATH": "/home/deck/homebrew/plugins"
"PLUGIN_PATH": "${workspaceFolder}/plugins"
},
"preLaunchTask": "Build frontend"
"preLaunchTask": "localrun"
}
]
}
+173 -6
View File
@@ -1,15 +1,182 @@
{
"version": "2.0.0",
"tasks": [
// OTHER
{
"label": "Stop Service",
"label": "checkforsettings",
"type": "shell",
"command":"systemctl --user stop plugin_loader",
"group": "none",
"detail": "Check that settings.json has been created",
"command": "bash -c ${workspaceFolder}/.vscode/config.sh",
"problemMatcher": []
},
{
"label": "Build frontend",
"label": "localrun",
"type": "shell",
"command":"cd ${workspaceFolder}/frontend; npm run build",
}
"group": "none",
"dependsOn": [
"buildall"
],
"detail": "Check for local runs, create a plugins folder",
"command": "mkdir -p plugins",
"problemMatcher": []
},
{
"label": "remoterun",
"type": "shell",
"group": "none",
"dependsOn": [
"updateremote",
"runpydeck"
],
"detail": "Task for remote run launches",
"command": "exit 0",
"problemMatcher": []
},
{
"label": "dependencies",
"type": "shell",
"group": "none",
"detail": "Check for local runs, create a plugins folder",
"command": "rsync -azp --rsh='ssh -p ${config:deckport} ${config:deckkey}' requirements.txt deck@${config:deckip}:${config:deckdir}/homebrew/dev/pluginloader/requirements.txt && ssh deck@${config:deckip} -p ${config:deckport} ${config:deckkey} 'python -m ensurepip && python -m pip install --upgrade pip && python -m pip install --upgrade setuptools && python -m pip install -r ${config:deckdir}/homebrew/dev/pluginloader/requirements.txt'",
"problemMatcher": []
},
// BUILD
{
"label": "pnpmsetup",
"type": "shell",
"group": "build",
"detail": "Setup pnpm",
"command": "cd frontend && pnpm i",
"problemMatcher": []
},
{
"script": "watch",
"type": "npm",
"path": "frontend",
"group": "build",
"problemMatcher": [],
"label": "watchfrontend",
"detail": "rollup -c -w",
"isBackground": true
},
{
"label": "buildfrontend",
"type": "npm",
"group": "build",
"detail": "rollup -c",
"script": "build",
"path": "frontend",
"problemMatcher": []
},
{
"label": "buildall",
"group": "build",
"detail": "Deploy pluginloader to deck",
"dependsOrder": "sequence",
"dependsOn": [
"pnpmsetup",
"buildfrontend"
],
"problemMatcher": []
},
// DEPLOY
{
"label": "createfolders",
"detail": "Create plugins folder in expected directory",
"type": "shell",
"group": "none",
"dependsOn": [
"checkforsettings"
],
"command": "ssh deck@${config:deckip} -p ${config:deckport} ${config:deckkey} 'mkdir -p ${config:deckdir}/homebrew/dev/pluginloader && mkdir -p ${config:deckdir}/homebrew/dev/plugins'",
"problemMatcher": []
},
{
"label": "deploy",
"detail": "Deploy dev PluginLoader to deck",
"type": "shell",
"group": "none",
"command": "rsync -azp --delete --rsh='ssh -p ${config:deckport} ${config:deckkey}' --exclude='.git/' --exclude='.github/' --exclude='.vscode/' --exclude='frontend/' --exclude='dist/' --exclude='contrib/' --exclude='*.log' --exclude='requirements.txt' --exclude='backend/__pycache__/' --exclude='.gitignore' . deck@${config:deckip}:${config:deckdir}/homebrew/dev/pluginloader",
"problemMatcher": []
},
// RUN
{
"label": "runpydeck",
"detail": "Run indev PluginLoader on Deck",
"type": "shell",
"group": "none",
"dependsOn": [
"checkforsettings"
],
"command": "ssh deck@${config:deckip} -p ${config:deckport} ${config:deckkey} 'export PLUGIN_PATH=${config:deckdir}/homebrew/dev/plugins; export CHOWN_PLUGIN_PATH=0; export LOG_LEVEL=DEBUG; cd ${config:deckdir}/homebrew/services; echo '${config:deckpass}' | sudo -SE python3 ${config:deckdir}/homebrew/dev/pluginloader/backend/main.py'",
"problemMatcher": []
},
{
"label": "runpylocal",
"detail": "Run PluginLoader from python locally",
"type": "shell",
"group": "none",
"command": "export PLUGIN_PATH=${workspaceFolder}/plugins; export CHOWN_PLUGIN_PATH=0; sudo -E python3 ${workspaceFolder}/backend/main.py",
"problemMatcher": []
},
// ALL-IN-ONES
{
"label": "deployandrun",
"detail": "Deploy and run, skipping JS build. Useful when combined with npm:watch",
"dependsOrder": "sequence",
"group": {
"kind": "build",
"isDefault": true
},
"dependsOn": [
"deploy",
"runpydeck"
],
"problemMatcher": []
},
{
"label": "updateremote",
"detail": "Build and deploy",
"dependsOrder": "sequence",
"group": "none",
"dependsOn": [
"buildall",
"deploy"
],
"problemMatcher": []
},
{
"label": "updateandrun",
"detail": "Build, deploy and run",
"dependsOrder": "sequence",
"group": {
"kind": "build",
"isDefault": true
},
"dependsOn": [
"buildfrontend",
"deploy",
"runpydeck"
],
"problemMatcher": []
},
{
"label": "allinone",
"detail": "Build, install dependencies, deploy and run",
"dependsOrder": "sequence",
"group": {
"kind": "build",
"isDefault": false
},
"dependsOn": [
"buildall",
"createfolders",
"dependencies",
"deploy",
"runpydeck"
],
"problemMatcher": []
}
]
}
}
+96 -55
View File
@@ -1,70 +1,111 @@
# TODO
- Fix button size/display
- Add plugin installation prompts for browser
- Fix components not updating unless tab opened first (with new tab hook)
- Clean up code
<h1 align="center">
<a name="logo" href="https://deckbrew.xyz/"><img src="https://deckbrew.xyz/logo.png" alt="Deckbrew logo" width="200"></a>
<br>
Decky Loader
</h1>
# Plugin Loader [![Chat](https://img.shields.io/badge/chat-on%20discord-7289da.svg)](https://discord.gg/ZU74G2NJzk)
<p align="center">
<a href="https://github.com/SteamDeckHomebrew/decky-loader/releases"><img src="https://img.shields.io/github/downloads/SteamDeckHomebrew/decky-loader/total" /></a>
<a href="https://github.com/SteamDeckHomebrew/decky-loader/stargazers"><img src="https://img.shields.io/github/stars/SteamDeckHomebrew/decky-loader" /></a>
<a href="https://github.com/SteamDeckHomebrew/decky-loader/commits/main"><img src="https://img.shields.io/github/last-commit/SteamDeckHomebrew/decky-loader.svg" /></a>
<a href="https://github.com/SteamDeckHomebrew/decky-loader/blob/main/LICENSE"><img src="https://img.shields.io/github/license/SteamDeckHomebrew/decky-loader" /></a>
<a href="https://discord.gg/ZU74G2NJzk"><img src="https://img.shields.io/discord/960281551428522045?color=%235865F2&label=discord" /></a>
<br>
<br>
<img src="https://media.discordapp.net/attachments/966017112244125756/1012466063893610506/main.jpg" alt="Decky screenshot" width="80%">
</p>
![steamuserimages-a akamaihd](https://user-images.githubusercontent.com/10835354/161068262-ca723dc5-6795-417a-80f6-d8c1f9d03e93.jpg)
## 📖 About
Keep an eye on the [Wiki](https://deckbrew.xyz) for more information about Plugin Loader, documentation + tools for plugin development and more.
Decky Loader is a homebrew plugin launcher for the Steam Deck. It can be used to [stylize your menus](https://github.com/suchmememanyskill/SDH-CssLoader), [change system sounds](https://github.com/EMERALD0874/SDH-AudioLoader), [adjust your screen saturation](https://github.com/libvibrant/vibrantDeck), [change additional system settings](https://github.com/NGnius/PowerTools), and [more](https://plugins.deckbrew.xyz/).
## Installation
1. Go into the Steam Deck Settings
2. Under System -> System Settings toggle `Enable Developer Mode`
3. Scroll the sidebar all the way down and click on `Developer`
4. Under Miscellaneous, enable `CEF Remote Debugging`
5. Click on the `STEAM` button and select `Power` -> `Switch to Desktop`
6. Open a terminal and paste the following command into it:
- For users:
- `curl -L https://github.com/SteamDeckHomebrew/PluginLoader/raw/main/dist/install_release.sh | sh`
- For plugin developers:
~~- `curl -L https://github.com/SteamDeckHomebrew/PluginLoader/raw/main/dist/install_nightly.sh | sh`~~
Nightly releases are currently broken.
7. Done! Reboot back into Gaming mode and enjoy your plugins!
For more information about Decky Loader as well as documentation and development tools, please visit [our wiki](https://deckbrew.xyz).
### Install Plugins
- Using the shopping bag button in the top right corner, you can go to the offical ["Plugin Store"](https://plugins.deckbrew.xyz/)
- Simply copy the plugin's folder into `~/homebrew/plugins`
### 🎨 Features
### Uninstall
- Open a terminal and paste the following command into it:
- For both users and developers:
- `curl -L https://github.com/SteamDeckHomebrew/PluginLoader/raw/main/dist/uninstall.sh | sh`
🧹 Clean injecting and loading of multiple plugins.
🔒 Stays installed between system updates and reboots.
🔗 Allows two-way communication between plugins and the loader.
🐍 Supports Python functions run from TypeScript React.
🌐 Allows plugins to make fetch calls that bypass CORS completely.
## Features
- Clean injecting and loading of one or more plugins
- Persistent. It doesn't need to be reinstalled after every system update
- Allows 2-way communication between the plugins and the loader.
- Allows plugins to define python functions and run them from javascript.
- Allows plugins to make fetch calls, bypassing cors completely.
### 🤔 Common Issues
## Developing plugins
- There is no complete plugin development documentation yet. However a good starting point is the [Plugin Template](https://github.com/SteamDeckHomebrew/decky-plugin-template) repository.
- Crankshaft is incompatible with Decky Loader. If you are using Crankshaft, please uninstall it before installing Decky Loader.
- Syncthing may use port 8080 on Steam Deck, which Decky Loader needs to function. If you are using Syncthing as a service, please change its port to something else.
- If you are using any software that uses port 1337 or 8080, please change its port to something else or uninstall it.
## Contribution
- For Plugin Loader contributors (in possession of a Steam Deck):
- `curl -L https://github.com/SteamDeckHomebrew/PluginLoader/raw/react-frontend-plugins/contrib/deck.sh | sh`
- For PluginLoader contributors (without a Steam Deck):
- `curl -L https://github.com/SteamDeckHomebrew/PluginLoader/raw/react-frontend-plugins/contrib/pc.sh | sh`
- [Here's how to get the Steam Deck UI on your enviroment of choice.](https://youtu.be/1IAbZte8e7E?t=112)
- (The video shows Windows usage but unless you're using WSL/cygwin this script is unsupported on Windows.)
## 💾 Installation
1. Press the <img src="./docs/images/light/steam.svg#gh-dark-mode-only" height=16><img src="./docs/images/dark/steam.svg#gh-light-mode-only" height=16> button and open the Settings menu.
1. Navigate to the System menu and scroll to the System Settings. Toggle "Enable Developer Mode" so it is enabled.
1. Navigate to the Developer menu and scroll to Miscellaneous. Toggle "CEF Remote Debugging" so it is enabled.
1. Select "Restart Now" to apply your changes.
1. Prepare a mouse and keyboard if possible.
- Keyboards and mice can be connected to the Steam Deck via USB-C or Bluetooth.
- Many Bluetooth keyboard and mouse apps are available for iOS and Android.
- The Steam Link app is available on [Windows](https://media.steampowered.com/steamlink/windows/latest/SteamLink.zip), [macOS](https://apps.apple.com/us/app/steam-link/id1246969117), and [Linux](https://flathub.org/apps/details/com.valvesoftware.SteamLink). It works well as a remote desktop substitute.
- If you have no other options, use the right trackpad as a mouse and press <img src="./docs/images/light/steam.svg#gh-dark-mode-only" height=16><img src="./docs/images/dark/steam.svg#gh-light-mode-only" height=16>+<img src="./docs/images/light/x.svg#gh-dark-mode-only" height=16><img src="./docs/images/dark/x.svg#gh-light-mode-only" height=16> to open the on-screen keyboard as needed.
1. Press the <img src="./docs/images/light/steam.svg#gh-dark-mode-only" height=16><img src="./docs/images/dark/steam.svg#gh-light-mode-only" height=16> button and open the Power menu.
1. Select "Switch to Desktop".
1. Open the Konsole app and enter the command `passwd`. You can skip this step if you have already created a sudo password using this command. ([YouTube Guide](https://www.youtube.com/watch?v=1vOMYGj22rQ))
1. You will be prompted to create a password. Your text will not be visible. After you press enter, you will need to type your password again to confirm.
1. Choose the version of Decky Loader you want to install and paste the following command into the Konsole app.
- **Latest Release**
Intended for most users. This is the latest stable version of Decky Loader.
`curl -L https://github.com/SteamDeckHomebrew/decky-loader/raw/main/dist/install_release.sh | sh`
- **Latest Pre-Release**
Intended for plugin developers. Pre-releases are unlikely to be fully stable but contain the latest changes. For more information on plugin development, please consult [the wiki page](https://deckbrew.xyz/en/loader-dev/development).
`curl -L https://github.com/SteamDeckHomebrew/decky-loader/raw/main/dist/install_prerelease.sh | sh`
1. Open the Return to Gaming Mode shortcut on your desktop.
### 👋 Uninstallation
We are sorry to see you go! If you are considering uninstalling because you are having issues, please consider [opening an issue](https://github.com/SteamDeckHomebrew/decky-loader/issues) or [joining our Discord](https://discord.gg/ZU74G2NJzk) so we can help you and other users.
1. Press the <img src="./docs/images/light/steam.svg#gh-dark-mode-only" height=16><img src="./docs/images/dark/steam.svg#gh-light-mode-only" height=16> button and open the Power menu.
1. Select "Switch to Desktop".
1. Open the Konsole app and run `curl -L https://github.com/SteamDeckHomebrew/decky-loader/raw/main/dist/uninstall.sh | sh`.
## 🚀 Getting Started
Now that you have Decky Loader installed, you can start using plugins. Each plugin is maintained by a different developer and has its own uses, but most follow a general structure outlined below.
### 📦 Plugins
1. Press the <img src="./docs/images/light/qam.svg#gh-dark-mode-only" height=16><img src="./docs/images/dark/qam.svg#gh-light-mode-only" height=16> button and navigate to the <img src="./docs/images/light/plug.svg#gh-dark-mode-only" height=16><img src="./docs/images/dark/plug.svg#gh-light-mode-only" height=16> icon. This is the Decky menu used for interacting with plugins and the loader itself.
1. Select the <img src="./docs/images/light/store.svg#gh-dark-mode-only" height=16><img src="./docs/images/dark/store.svg#gh-light-mode-only" height=16> icon to open the Plugins Browser. This is where you can find and install plugins.
- You can also install from URL in the Settings menu. We do not recommend installing plugins from untrusted sources.
1. To install a plugin, select the "Install" button on the plugin you want. You can also select a version from a dropdown menu, but this is not recommended.
1. To update, uninstall, and reload plugins, navigate to the Decky menu and select the <img src="./docs/images/light/gear.svg#gh-dark-mode-only" height=16><img src="./docs/images/dark/gear.svg#gh-light-mode-only" height=16> icon.
- Keep in mind that uninstalling a plugin will only remove its plugin files, not any other files it may have created.
### 🛠️ Plugin Development
There is no complete plugin development documentation yet. However a good starting point is the [plugin template repository](https://github.com/SteamDeckHomebrew/decky-plugin-template). Consider [joining our Discord](https://discord.gg/ZU74G2NJzk) if you have any questions.
### 🤝 Contributing
Please consult [the wiki page regarding development](https://deckbrew.xyz/en/loader-dev/development) for more information on installing development versions of Decky Loader. You can also install the Steam Deck UI on a Windows or Linux computer for testing by following [this YouTube guide](https://youtu.be/1IAbZte8e7E?t=112).
1. Clone the repository using the latest commit to main before starting your PR.
1. In your clone of the repository, run these commands.
```bash
pnpm i
pnpm run build
```
1. If you are modifying the UI, these commands will need to be run before deploying the changes to your Steam Deck.
1. Use the VS Code tasks or `deck.sh` script to deploy your changes to your Steam Deck to test them.
1. You will be testing your changes with the Python script version. You will need to build, deploy, and reload each time.
⚠️ If you are recieving build errors due to an out of date library, you should run this command inside of your repository.
To run your development version of Plugin Loader on Deck, run a command like this:
```bash
ssh deck@steamdeck 'export PLUGIN_PATH=/home/deck/loaderdev/plugins; export CHOWN_PLUGIN_PATH=0; echo 'password' | sudo -SE python3 /home/deck/loaderdev/pluginloader/backend/main.py'
pnpm update decky-frontend-lib --latest
```
Or on PC with the Deck UI enabled:
```bash
export PLUGIN_PATH=/home/user/installdirectory/plugins;
export CHOWN_PLUGIN_PATH=0;
sudo python3 /home/deck/loaderdev/pluginloader/backend/main.py
```
Source control and deploying plugins are left to each respective contributor for the cloned repos in order to keep dependencies up to date.
Source control and deploying plugins are left to each respective contributor for the cloned repos in order to keep depedencies up to date.
## 📜 Credits
## Credit
The original idea for the concept is based on the work of [marios8543's steamdeck-ui-inject](https://github.com/marios8543/steamdeck-ui-inject) project.
The original idea for the plugin loader concept is based on the work of [marios8543's Steam Deck UI Inject project](https://github.com/marios8543/steamdeck-ui-inject).
+10
View File
@@ -0,0 +1,10 @@
this directory contains artifacts generated by invocations of https://github.com/nektos/act in order to do local testing of binary builds
how to?
run:
./act/run-act.sh prerelease
or
./act/run-act.sh release
+6
View File
@@ -0,0 +1,6 @@
{
"inputs": {
"release": "prerelease",
"bump": "none"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"inputs": {
"release": "release",
"bump": "none"
}
}
Executable
+45
View File
@@ -0,0 +1,45 @@
#!/bin/bash
type=$1
# bump=$2
oldartifactsdir="old"
parent_path=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P )
cd "$parent_path"
artifactfolders=$(find artifacts/ -maxdepth 1 -mindepth 1 -type d)
if [[ ${#artifactfolders[@]} > 0 ]]; then
for i in ${artifactfolders[@]}; do
foldername=$(dirname $i)
subfoldername=$(basename $i)
out=$foldername/$oldartifactsdir/$subfoldername-$(date +'%s')
if [[ ! "$subfoldername" =~ "$oldartifactsdir" ]]; then
mkdir -p $out
mv $i $out
printf "Moved "${foldername}"/"${subfoldername}" to "${out}" \n"
fi
done
fi
cd ..
if [[ "$type" == "release" ]]; then
printf "release!\n"
act workflow_dispatch -e act/release.json --artifact-server-path act/artifacts
elif [[ "$type" == "prerelease" ]]; then
printf "prerelease!\n"
act workflow_dispatch -e act/prerelease.json --artifact-server-path act/artifacts
else
printf "Release type unspecified/badly specified.\n"
printf "Options: 'release' or 'prerelease'\n"
fi
cd act/artifacts
if [[ -d "1" ]]; then
cd "1/artifact"
cp "PluginLoader.gz__" "PluginLoader.gz"
gzip -d "PluginLoader.gz"
chmod +x PluginLoader
fi
+156 -64
View File
@@ -1,92 +1,184 @@
from injector import get_tab
from logging import getLogger
from os import path, rename
from shutil import rmtree
# Full imports
import json
# Partial imports
from aiohttp import ClientSession, web
from io import BytesIO
from zipfile import ZipFile
from asyncio import get_event_loop, sleep
from concurrent.futures import ProcessPoolExecutor
from asyncio import get_event_loop
from time import time
from hashlib import sha256
from subprocess import Popen
from io import BytesIO
from logging import getLogger
from os import R_OK, W_OK, path, rename, listdir, access, mkdir
from shutil import rmtree
from subprocess import call
from time import time
from zipfile import ZipFile
# Local modules
from helpers import get_ssl_context, get_user, get_user_group, download_remote_binary_to_path
from injector import get_gamepadui_tab
logger = getLogger("Browser")
class PluginInstallContext:
def __init__(self, gh_url, version, hash) -> None:
self.gh_url = gh_url
def __init__(self, artifact, name, version, hash) -> None:
self.artifact = artifact
self.name = name
self.version = version
self.hash = hash
class PluginBrowser:
def __init__(self, plugin_path, server_instance, store_url) -> None:
self.log = getLogger("browser")
def __init__(self, plugin_path, plugins, loader) -> None:
self.plugin_path = plugin_path
self.store_url = store_url
self.plugins = plugins
self.loader = loader
self.install_requests = {}
server_instance.add_routes([
web.post("/browser/install_plugin", self.install_plugin),
web.get("/browser/redirect", self.redirect_to_store)
])
def _unzip_to_plugin_dir(self, zip, name, hash):
zip_hash = sha256(zip.getbuffer()).hexdigest()
if zip_hash != hash:
if hash and (zip_hash != hash):
return False
zip_file = ZipFile(zip)
zip_file.extractall(self.plugin_path)
rename(path.join(self.plugin_path, zip_file.namelist()[0]), path.join(self.plugin_path, name))
Popen(["chown", "-R", "deck:deck", self.plugin_path])
Popen(["chmod", "-R", "555", self.plugin_path])
plugin_dir = self.find_plugin_folder(name)
code_chown = call(["chown", "-R", get_user()+":"+get_user_group(), plugin_dir])
code_chmod = call(["chmod", "-R", "555", plugin_dir])
if code_chown != 0 or code_chmod != 0:
logger.error(f"chown/chmod exited with a non-zero exit code (chown: {code_chown}, chmod: {code_chmod})")
return False
return True
async def _download_remote_binaries_for_plugin_with_name(self, pluginBasePath):
rv = False
try:
packageJsonPath = path.join(pluginBasePath, 'package.json')
pluginBinPath = path.join(pluginBasePath, 'bin')
async def _install(self, artifact, version, hash):
name = artifact.split("/")[-1]
rmtree(path.join(self.plugin_path, name), ignore_errors=True)
self.log.info(f"Installing {artifact} (Version: {version})")
async with ClientSession() as client:
url = f"https://github.com/{artifact}/archive/refs/tags/{version}.zip"
self.log.debug(f"Fetching {url}")
res = await client.get(url)
if res.status == 200:
self.log.debug("Got 200. Reading...")
data = await res.read()
self.log.debug(f"Read {len(data)} bytes")
res_zip = BytesIO(data)
with ProcessPoolExecutor() as executor:
self.log.debug("Unzipping...")
ret = await get_event_loop().run_in_executor(
executor,
self._unzip_to_plugin_dir,
res_zip,
name,
hash
)
if ret:
self.log.info(f"Installed {artifact} (Version: {version})")
if access(packageJsonPath, R_OK):
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])
if access(pluginBasePath, W_OK):
if not path.exists(pluginBinPath):
mkdir(pluginBinPath)
if not access(pluginBinPath, W_OK):
rc=call(["chmod", "-R", "777", pluginBinPath])
rv = True
for remoteBinary in packageJson["remote_binary"]:
# Required Fields. If any Remote Binary is missing these fail the install.
binName = remoteBinary["name"]
binURL = remoteBinary["url"]
binHash = remoteBinary["sha256hash"]
if not await download_remote_binary_to_path(binURL, binHash, path.join(pluginBinPath, binName)):
rv = False
raise Exception(f"Error Downloading Remote Binary {binName}@{binURL} with hash {binHash} to {path.join(pluginBinPath, binName)}")
code_chown = call(["chown", "-R", get_user()+":"+get_user_group(), self.plugin_path])
rc=call(["chmod", "-R", "555", pluginBasePath])
else:
self.log.fatal(f"SHA-256 Mismatch!!!! {artifact} (Version: {version})")
rv = True
logger.debug(f"No Remote Binaries to Download")
except Exception as e:
rv = False
logger.debug(str(e))
return rv
def find_plugin_folder(self, name):
for folder in listdir(self.plugin_path):
try:
with open(path.join(self.plugin_path, folder, 'plugin.json'), "r", encoding="utf-8") as f:
plugin = json.load(f)
if plugin['name'] == name:
return str(path.join(self.plugin_path, folder))
except:
logger.debug(f"skipping {folder}")
async def uninstall_plugin(self, name):
if self.loader.watcher:
self.loader.watcher.disabled = True
tab = await get_gamepadui_tab()
try:
logger.info("uninstalling " + name)
logger.info(" at dir " + self.find_plugin_folder(name))
logger.debug("unloading %s" % str(name))
await tab.evaluate_js(f"DeckyPluginLoader.unloadPlugin('{name}')")
if self.plugins[name]:
self.plugins[name].stop()
del self.plugins[name]
logger.debug("removing files %s" % str(name))
rmtree(self.find_plugin_folder(name))
except FileNotFoundError:
logger.warning(f"Plugin {name} not installed, skipping uninstallation")
except Exception as e:
logger.error(f"Plugin {name} in {self.find_plugin_folder(name)} was not uninstalled")
logger.error(f"Error at %s", exc_info=e)
if self.loader.watcher:
self.loader.watcher.disabled = False
async def _install(self, artifact, name, version, hash):
isInstalled = False
if self.loader.watcher:
self.loader.watcher.disabled = True
try:
pluginFolderPath = self.find_plugin_folder(name)
if pluginFolderPath:
isInstalled = True
except:
logger.error(f"Failed to determine if {name} is already installed, continuing anyway.")
logger.info(f"Installing {name} (Version: {version})")
async with ClientSession() as client:
logger.debug(f"Fetching {artifact}")
res = await client.get(artifact, ssl=get_ssl_context())
if res.status == 200:
logger.debug("Got 200. Reading...")
data = await res.read()
logger.debug(f"Read {len(data)} bytes")
res_zip = BytesIO(data)
if isInstalled:
try:
logger.debug("Uninstalling existing plugin...")
await self.uninstall_plugin(name)
except:
logger.error(f"Plugin {name} could not be uninstalled.")
logger.debug("Unzipping...")
ret = self._unzip_to_plugin_dir(res_zip, name, hash)
if ret:
plugin_dir = self.find_plugin_folder(name)
ret = await self._download_remote_binaries_for_plugin_with_name(plugin_dir)
if ret:
logger.info(f"Installed {name} (Version: {version})")
if name in self.loader.plugins:
self.loader.plugins[name].stop()
self.loader.plugins.pop(name, None)
await sleep(1)
self.loader.import_plugin(path.join(plugin_dir, "main.py"), plugin_dir)
else:
logger.fatal(f"Failed Downloading Remote Binaries")
else:
self.log.fatal(f"SHA-256 Mismatch!!!! {name} (Version: {version})")
if self.loader.watcher:
self.loader.watcher.disabled = False
else:
self.log.fatal(f"Could not fetch from github. {await res.text()}")
logger.fatal(f"Could not fetch from URL. {await res.text()}")
async def redirect_to_store(self, request):
return web.Response(status=302, headers={"Location": self.store_url})
async def install_plugin(self, request):
data = await request.post()
get_event_loop().create_task(self.request_plugin_install(data["artifact"], data["version"], data["hash"]))
return web.Response(text="Requested plugin install")
async def request_plugin_install(self, artifact, version, hash):
async def request_plugin_install(self, artifact, name, version, hash):
request_id = str(time())
self.install_requests[request_id] = PluginInstallContext(artifact, version, hash)
tab = await get_tab("SP")
self.install_requests[request_id] = PluginInstallContext(artifact, name, version, hash)
tab = await get_gamepadui_tab()
await tab.open_websocket()
await tab.evaluate_js(f"DeckyPluginLoader.addPluginInstallPrompt('{artifact}', '{version}', '{request_id}')")
await tab.evaluate_js(f"DeckyPluginLoader.addPluginInstallPrompt('{name}', '{version}', '{request_id}', '{hash}')")
async def confirm_plugin_install(self, request_id):
request = self.install_requests.pop(request_id)
await self._install(request.gh_url, request.version, request.hash)
await self._install(request.artifact, request.name, request.version, request.hash)
def cancel_plugin_install(self, request_id):
self.install_requests.pop(request_id)
self.install_requests.pop(request_id)
+136
View File
@@ -0,0 +1,136 @@
import grp
import pwd
import re
import ssl
import subprocess
import uuid
import os
from subprocess import check_output
from time import sleep
from hashlib import sha256
from io import BytesIO
import certifi
from aiohttp.web import Response, middleware
from aiohttp import ClientSession
REMOTE_DEBUGGER_UNIT = "steam-web-debug-portforward.service"
# global vars
csrf_token = str(uuid.uuid4())
ssl_ctx = ssl.create_default_context(cafile=certifi.where())
user = None
group = None
assets_regex = re.compile("^/plugins/.*/assets/.*")
frontend_regex = re.compile("^/frontend/.*")
def get_ssl_context():
return ssl_ctx
def get_csrf_token():
return csrf_token
@middleware
async def csrf_middleware(request, handler):
if str(request.method) == "OPTIONS" or request.headers.get('Authentication') == csrf_token or str(request.rel_url) == "/auth/token" or str(request.rel_url).startswith("/plugins/load_main/") or str(request.rel_url).startswith("/static/") or str(request.rel_url).startswith("/legacy/") or str(request.rel_url).startswith("/steam_resource/") or str(request.rel_url).startswith("/frontend/") or assets_regex.match(str(request.rel_url)) or frontend_regex.match(str(request.rel_url)):
return await handler(request)
return Response(text='Forbidden', status='403')
# Get the user by checking for the first logged in user. As this is run
# by systemd at startup the process is likely to start before the user
# logs in, so we will wait here until they are available. Note that
# other methods such as getenv wont work as there was no $SUDO_USER to
# start the systemd service.
def set_user():
global user
cmd = "who | awk '{print $1}' | sort | head -1"
while user == None:
name = check_output(cmd, shell=True).decode().strip()
if name not in [None, '']:
user = name
sleep(0.1)
# Get the global user. get_user must be called first.
def get_user() -> str:
global user
if user == None:
raise ValueError("helpers.get_user method called before user variable was set. Run helpers.set_user first.")
return user
#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)[0]
#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)[0]
# Set the global user group. get_user must be called first
def set_user_group() -> str:
global group
global user
if user == None:
raise ValueError("helpers.set_user_dir method called before user variable was set. Run helpers.set_user first.")
if group == None:
group = check_output(["id", "-g", "-n", user]).decode().strip()
# Get the group of the global user. set_user_group must be called first.
def get_user_group() -> str:
global group
if group == None:
raise ValueError("helpers.get_user_group method called before group variable was set. Run helpers.set_user_group first.")
return group
# Get the default home path unless a user is specified
def get_home_path(username = None) -> str:
if username == None:
raise ValueError("Username not defined, no home path can be found.")
else:
return str("/home/"+username)
# Get the default homebrew path unless a user is specified
def get_homebrew_path(home_path = None) -> str:
if home_path == None:
raise ValueError("Home path not defined, homebrew dir cannot be determined.")
else:
return str(home_path+"/homebrew")
# return str(home_path+"/homebrew")
# Download Remote Binaries to local Plugin
async def download_remote_binary_to_path(url, binHash, path) -> bool:
rv = False
try:
if os.access(os.path.dirname(path), os.W_OK):
async with ClientSession() as client:
res = await client.get(url, ssl=get_ssl_context())
if res.status == 200:
data = BytesIO(await res.read())
remoteHash = sha256(data.getbuffer()).hexdigest()
if binHash == remoteHash:
data.seek(0)
with open(path, 'wb') as f:
f.write(data.getbuffer())
rv = True
else:
raise Exception(f"Fatal Error: Hash Mismatch for remote binary {path}@{url}")
else:
rv = False
except:
rv = False
return rv
async def is_systemd_unit_active(unit_name: str) -> bool:
res = subprocess.run(["systemctl", "is-active", unit_name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return res.returncode == 0
async def stop_systemd_unit(unit_name: str) -> subprocess.CompletedProcess:
cmd = ["systemctl", "stop", unit_name]
return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
async def start_systemd_unit(unit_name: str) -> subprocess.CompletedProcess:
cmd = ["systemctl", "start", unit_name]
return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
+354 -58
View File
@@ -1,19 +1,27 @@
#Injector code from https://github.com/SteamDeckHomebrew/steamdeck-ui-inject. More info on how it works there.
# Injector code from https://github.com/SteamDeckHomebrew/steamdeck-ui-inject. More info on how it works there.
from asyncio import sleep
from logging import debug, getLogger
from logging import getLogger
from traceback import format_exc
from typing import List
from aiohttp import ClientSession
from aiohttp import ClientSession, WSMsgType
from aiohttp.client_exceptions import ClientConnectorError, ClientOSError
from asyncio.exceptions import TimeoutError
import uuid
BASE_ADDRESS = "http://localhost:8080"
logger = getLogger("Injector")
class Tab:
cmd_id = 0
def __init__(self, res) -> None:
self.title = res["title"]
self.id = res["id"]
self.url = res["url"]
self.ws_url = res["webSocketDebuggerUrl"]
self.websocket = None
@@ -23,31 +31,318 @@ class Tab:
self.client = ClientSession()
self.websocket = await self.client.ws_connect(self.ws_url)
async def close_websocket(self):
await self.websocket.close()
await self.client.close()
async def listen_for_message(self):
async for message in self.websocket:
yield message
data = message.json()
yield data
logger.warn(f"The Tab {self.title} socket has been disconnected while listening for messages.")
await self.close_websocket()
async def _send_devtools_cmd(self, dc, receive=True):
if self.websocket:
self.cmd_id += 1
dc["id"] = self.cmd_id
await self.websocket.send_json(dc)
return (await self.websocket.receive_json()) if receive else None
if receive:
async for msg in self.listen_for_message():
if "id" in msg and msg["id"] == dc["id"]:
return msg
return None
raise RuntimeError("Websocket not opened")
async def evaluate_js(self, js, run_async=False):
await self.open_websocket()
res = await self._send_devtools_cmd({
"id": 1,
"method": "Runtime.evaluate",
"params": {
"expression": js,
"userGesture": True,
"awaitPromise": run_async
}
})
async def evaluate_js(self, js, run_async=False, manage_socket=True, get_result=True):
try:
if manage_socket:
await self.open_websocket()
await self.client.close()
res = await self._send_devtools_cmd({
"method": "Runtime.evaluate",
"params": {
"expression": js,
"userGesture": True,
"awaitPromise": run_async
}
}, get_result)
finally:
if manage_socket:
await self.close_websocket()
return res
async def has_global_var(self, var_name, manage_socket=True):
res = await self.evaluate_js(f"window['{var_name}'] !== null && window['{var_name}'] !== undefined", False, manage_socket)
if not "result" in res or not "result" in res["result"] or not "value" in res["result"]["result"]:
return False
return res["result"]["result"]["value"]
async def close(self, manage_socket=True):
try:
if manage_socket:
await self.open_websocket()
res = await self._send_devtools_cmd({
"method": "Page.close",
}, False)
finally:
if manage_socket:
await self.close_websocket()
return res
async def enable(self):
"""
Enables page domain notifications.
"""
await self._send_devtools_cmd({
"method": "Page.enable",
}, False)
async def disable(self):
"""
Disables page domain notifications.
"""
await self._send_devtools_cmd({
"method": "Page.disable",
}, False)
async def refresh(self):
try:
if manage_socket:
await self.open_websocket()
await self._send_devtools_cmd({
"method": "Page.reload",
}, False)
finally:
if manage_socket:
await self.close_websocket()
return
async def reload_and_evaluate(self, js, manage_socket=True):
"""
Reloads the current tab, with JS to run on load via debugger
"""
try:
if manage_socket:
await self.open_websocket()
await self._send_devtools_cmd({
"method": "Debugger.enable"
}, True)
await self._send_devtools_cmd({
"method": "Runtime.evaluate",
"params": {
"expression": "location.reload();",
"userGesture": True,
"awaitPromise": False
}
}, False)
breakpoint_res = await self._send_devtools_cmd({
"method": "Debugger.setInstrumentationBreakpoint",
"params": {
"instrumentation": "beforeScriptExecution"
}
}, True)
logger.info(breakpoint_res)
# Page finishes loading when breakpoint hits
for x in range(20):
# this works around 1/5 of the time, so just send it 8 times.
# the js accounts for being injected multiple times allowing only one instance to run at a time anyway
await self._send_devtools_cmd({
"method": "Runtime.evaluate",
"params": {
"expression": js,
"userGesture": True,
"awaitPromise": False
}
}, False)
await self._send_devtools_cmd({
"method": "Debugger.removeBreakpoint",
"params": {
"breakpointId": breakpoint_res["result"]["breakpointId"]
}
}, False)
for x in range(4):
await self._send_devtools_cmd({
"method": "Debugger.resume"
}, False)
await self._send_devtools_cmd({
"method": "Debugger.disable"
}, True)
finally:
if manage_socket:
await self.close_websocket()
return
async def add_script_to_evaluate_on_new_document(self, js, add_dom_wrapper=True, manage_socket=True, get_result=True):
"""
How the underlying call functions is not particularly clear from the devtools docs, so stealing puppeteer's description:
Adds a function which would be invoked in one of the following scenarios:
* whenever the page is navigated
* whenever the child frame is attached or navigated. In this case, the
function is invoked in the context of the newly attached frame.
The function is invoked after the document was created but before any of
its scripts were run. This is useful to amend the JavaScript environment,
e.g. to seed `Math.random`.
Parameters
----------
js : str
The script to evaluate on new document
add_dom_wrapper : bool
True to wrap the script in a wait for the 'DOMContentLoaded' event.
DOM will usually not exist when this execution happens,
so it is necessary to delay til DOM is loaded if you are modifying it
manage_socket : bool
True to have this function handle opening/closing the websocket for this tab
get_result : bool
True to wait for the result of this call
Returns
-------
int or None
The identifier of the script added, used to remove it later.
(see remove_script_to_evaluate_on_new_document below)
None is returned if `get_result` is False
"""
try:
wrappedjs = """
function scriptFunc() {
{js}
}
if (document.readyState === 'loading') {
addEventListener('DOMContentLoaded', () => {
scriptFunc();
});
} else {
scriptFunc();
}
""".format(js=js) if add_dom_wrapper else js
if manage_socket:
await self.open_websocket()
res = await self._send_devtools_cmd({
"method": "Page.addScriptToEvaluateOnNewDocument",
"params": {
"source": wrappedjs
}
}, get_result)
finally:
if manage_socket:
await self.close_websocket()
return res
async def remove_script_to_evaluate_on_new_document(self, script_id, manage_socket=True):
"""
Removes a script from a page that was added with `add_script_to_evaluate_on_new_document`
Parameters
----------
script_id : int
The identifier of the script to remove (returned from `add_script_to_evaluate_on_new_document`)
"""
try:
if manage_socket:
await self.open_websocket()
res = await self._send_devtools_cmd({
"method": "Page.removeScriptToEvaluateOnNewDocument",
"params": {
"identifier": script_id
}
}, False)
finally:
if manage_socket:
await self.close_websocket()
async def has_element(self, element_name, manage_socket=True):
res = await self.evaluate_js(f"document.getElementById('{element_name}') != null", False, manage_socket)
if not "result" in res or not "result" in res["result"] or not "value" in res["result"]["result"]:
return False
return res["result"]["result"]["value"]
async def inject_css(self, style, manage_socket=True):
try:
css_id = str(uuid.uuid4())
result = await self.evaluate_js(
f"""
(function() {{
const style = document.createElement('style');
style.id = "{css_id}";
document.head.append(style);
style.textContent = `{style}`;
}})()
""", False, manage_socket)
if "exceptionDetails" in result["result"]:
return {
"success": False,
"result": result["result"]
}
return {
"success": True,
"result": css_id
}
except Exception as e:
return {
"success": False,
"result": e
}
async def remove_css(self, css_id, manage_socket=True):
try:
result = await self.evaluate_js(
f"""
(function() {{
let style = document.getElementById("{css_id}");
if (style.nodeName.toLowerCase() == 'style')
style.parentNode.removeChild(style);
}})()
""", False, manage_socket)
if "exceptionDetails" in result["result"]:
return {
"success": False,
"result": result
}
return {
"success": True
}
except Exception as e:
return {
"success": False,
"result": e
}
async def get_steam_resource(self, url):
res = await self.evaluate_js(f'(async function test() {{ return await (await fetch("{url}")).text() }})()', True)
return res["result"]["result"]["value"]
@@ -55,57 +350,58 @@ class Tab:
def __repr__(self):
return self.title
async def get_tabs():
async with ClientSession() as web:
res = {}
while True:
try:
res = await web.get(f"{BASE_ADDRESS}/json")
break
except:
async def get_tabs() -> List[Tab]:
res = {}
na = False
while True:
try:
async with ClientSession() as web:
res = await web.get(f"{BASE_ADDRESS}/json", timeout=3)
except ClientConnectorError:
if not na:
logger.debug("Steam isn't available yet. Wait for a moment...")
logger.debug(format_exc())
await sleep(5)
if res.status == 200:
r = await res.json()
return [Tab(i) for i in r]
na = True
await sleep(5)
except ClientOSError:
logger.warn(f"The request to {BASE_ADDRESS}/json was reset")
await sleep(1)
except TimeoutError:
logger.warn(f"The request to {BASE_ADDRESS}/json timed out")
await sleep(1)
else:
raise Exception(f"/json did not return 200. {await r.text()}")
break
async def get_tab(tab_name):
if res.status == 200:
r = await res.json()
return [Tab(i) for i in r]
else:
raise Exception(f"/json did not return 200. {await res.text()}")
async def get_tab(tab_name) -> Tab:
tabs = await get_tabs()
tab = next((i for i in tabs if i.title == tab_name), None)
if not tab:
raise ValueError(f"Tab {tab_name} not found")
return tab
async def get_tab_lambda(test) -> Tab:
tabs = await get_tabs()
tab = next((i for i in tabs if test(i)), None)
if not tab:
raise ValueError(f"Tab not found by lambda")
return tab
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)
if not tab:
raise ValueError(f"GamepadUI Tab not found")
return tab
async def inject_to_tab(tab_name, js, run_async=False):
tab = await get_tab(tab_name)
return await tab.evaluate_js(js, run_async)
async def tab_has_global_var(tab_name, var_name):
try:
tab = await get_tab(tab_name)
except ValueError:
return False
res = await tab.evaluate_js(f"window['{var_name}'] !== null && window['{var_name}'] !== undefined", False)
if not "result" in res or not "result" in res["result"] or not "value" in res["result"]["result"]:
return False
return res["result"]["result"]["value"]
async def tab_has_element(tab_name, element_name):
try:
tab = await get_tab(tab_name)
except ValueError:
return False
res = await tab.evaluate_js(f"document.getElementById('{element_name}') != null", False)
if not "result" in res or not "result" in res["result"] or not "value" in res["result"]["result"]:
return False
return res["result"]["result"]["value"]
+84
View File
@@ -0,0 +1,84 @@
class PluginEventTarget extends EventTarget { }
method_call_ev_target = new PluginEventTarget();
window.addEventListener("message", function(evt) {
let ev = new Event(evt.data.call_id);
ev.data = evt.data.result;
method_call_ev_target.dispatchEvent(ev);
}, false);
async function call_server_method(method_name, arg_object={}) {
const token = await fetch("http://127.0.0.1:1337/auth/token").then(r => r.text());
const response = await fetch(`http://127.0.0.1:1337/methods/${method_name}`, {
method: 'POST',
credentials: "include",
headers: {
'Content-Type': 'application/json',
Authentication: token
},
body: JSON.stringify(arg_object),
});
const dta = await response.json();
if (!dta.success) throw dta.result;
return dta.result;
}
// Source: https://stackoverflow.com/a/2117523 Thanks!
function uuidv4() {
return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
);
}
async function fetch_nocors(url, request={}) {
let args = { method: "POST", headers: {}, body: "" };
request = {...args, ...request};
request.url = url;
request.data = request.body;
delete request.body; //maintain api-compatibility with fetch
return await call_server_method("http_request", request);
}
async function call_plugin_method(method_name, arg_object={}) {
if (plugin_name == undefined)
throw new Error("Plugin methods can only be called from inside plugins (duh)");
const token = await fetch("http://127.0.0.1:1337/auth/token").then(r => r.text());
const response = await fetch(`http://127.0.0.1:1337/plugins/${plugin_name}/methods/${method_name}`, {
method: 'POST',
credentials: "include",
headers: {
'Content-Type': 'application/json',
Authentication: token
},
body: JSON.stringify({
args: arg_object,
}),
});
const dta = await response.json();
if (!dta.success) throw dta.result;
return dta.result;
}
async function execute_in_tab(tab, run_async, code) {
return await call_server_method("execute_in_tab", {
'tab': tab,
'run_async': run_async,
'code': code
});
}
async function inject_css_into_tab(tab, style) {
return await call_server_method("inject_css_into_tab", {
'tab': tab,
'style': style
});
}
async function remove_css_from_tab(tab, css_id) {
return await call_server_method("remove_css_from_tab", {
'tab': tab,
'css_id': css_id
});
}
+39 -18
View File
@@ -1,4 +1,4 @@
from asyncio import Queue
from asyncio import Queue, sleep
from json.decoder import JSONDecodeError
from logging import getLogger
from os import listdir, path
@@ -15,7 +15,7 @@ try:
except UnsupportedLibc:
from watchdog.observers.fsevents import FSEventsObserver as Observer
from injector import get_tab, inject_to_tab
from injector import get_tab, get_gamepadui_tab
from plugin import PluginWrapper
@@ -25,8 +25,11 @@ class FileChangeHandler(RegexMatchingEventHandler):
self.logger = getLogger("file-watcher")
self.plugin_path = plugin_path
self.queue = queue
self.disabled = True
def maybe_reload(self, src_path):
if self.disabled:
return
plugin_dir = Path(path.relpath(src_path, self.plugin_path)).parts[0]
if exists(path.join(self.plugin_path, plugin_dir, "plugin.json")):
self.queue.put_nowait((path.join(self.plugin_path, plugin_dir, "main.py"), plugin_dir, True))
@@ -66,19 +69,24 @@ class Loader:
self.plugin_path = plugin_path
self.logger.info(f"plugin_path: {self.plugin_path}")
self.plugins = {}
self.watcher = None
self.live_reload = live_reload
if live_reload:
self.reload_queue = Queue()
self.observer = Observer()
self.observer.schedule(FileChangeHandler(self.reload_queue, plugin_path), self.plugin_path, recursive=True)
self.watcher = FileChangeHandler(self.reload_queue, plugin_path)
self.observer.schedule(self.watcher, self.plugin_path, recursive=True)
self.observer.start()
self.loop.create_task(self.handle_reloads())
self.loop.create_task(self.enable_reload_wait())
server_instance.add_routes([
web.get("/frontend/{path:.*}", self.handle_frontend_assets),
web.get("/plugins", self.get_plugins),
web.get("/plugins/{plugin_name}/frontend_bundle", self.handle_frontend_bundle),
web.post("/plugins/{plugin_name}/methods/{method_name}", self.handle_plugin_method_call),
web.get("/plugins/{plugin_name}/assets/{path:.*}", self.handle_frontend_assets),
web.get("/plugins/{plugin_name}/assets/{path:.*}", self.handle_plugin_frontend_assets),
# The following is legacy plugin code.
web.get("/plugins/load_main/{name}", self.load_plugin_main_view),
@@ -86,23 +94,34 @@ class Loader:
web.get("/steam_resource/{path:.+}", self.get_steam_resource)
])
async def enable_reload_wait(self):
if self.live_reload:
await sleep(10)
self.logger.info("Hot reload enabled")
self.watcher.disabled = False
async def handle_frontend_assets(self, request):
file = path.join(path.dirname(__file__), "static", request.match_info["path"])
return web.FileResponse(file, headers={"Cache-Control": "no-cache"})
async def get_plugins(self, request):
plugins = list(self.plugins.values())
return web.json_response([str(i) if not i.legacy else "$LEGACY_"+str(i) for i in plugins])
return web.json_response([{"name": str(i) if not i.legacy else "$LEGACY_"+str(i), "version": i.version} for i in plugins])
def handle_frontend_assets(self, request):
def handle_plugin_frontend_assets(self, request):
plugin = self.plugins[request.match_info["plugin_name"]]
file = path.join(self.plugin_path, plugin.plugin_directory, "dist/assets", request.match_info["path"])
return web.FileResponse(file)
return web.FileResponse(file, headers={"Cache-Control": "no-cache"})
def handle_frontend_bundle(self, request):
plugin = self.plugins[request.match_info["plugin_name"]]
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):
def import_plugin(self, file, plugin_directory, refresh=False, batch=False):
try:
plugin = PluginWrapper(file, plugin_directory, self.plugin_path)
if plugin.name in self.plugins:
@@ -116,13 +135,15 @@ class Loader:
self.logger.info(f"Plugin {plugin.name} is passive")
self.plugins[plugin.name] = plugin.start()
self.logger.info(f"Loaded {plugin.name}")
self.loop.create_task(self.dispatch_plugin(plugin.name))
if not batch:
self.loop.create_task(self.dispatch_plugin(plugin.name if not plugin.legacy else "$LEGACY_" + plugin.name, plugin.version))
except Exception as e:
self.logger.error(f"Could not load {file}. {e}")
print_exc()
async def dispatch_plugin(self, name):
await inject_to_tab("SP", f"window.importDeckyPlugin('{name}')")
async def dispatch_plugin(self, name, version):
gpui_tab = await get_gamepadui_tab()
await gpui_tab.evaluate_js(f"window.importDeckyPlugin('{name}', '{version}')")
def import_plugins(self):
self.logger.info(f"import plugins from {self.plugin_path}")
@@ -130,7 +151,7 @@ class Loader:
directories = [i for i in listdir(self.plugin_path) if path.isdir(path.join(self.plugin_path, i)) and path.isfile(path.join(self.plugin_path, i, "plugin.json"))]
for directory in directories:
self.logger.info(f"found plugin: {directory}")
self.import_plugin(path.join(self.plugin_path, directory, "main.py"), directory)
self.import_plugin(path.join(self.plugin_path, directory, "main.py"), directory, False, True)
async def handle_reloads(self):
while True:
@@ -165,11 +186,11 @@ 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="/static/legacy-library.js"></script>
<script>const plugin_name = '{plugin.name}' </script>
<script src="/legacy/library.js"></script>
<script>window.plugin_name = '{plugin.name}' </script>
<base href="http://127.0.0.1:1337/plugins/plugin_resource/{plugin.name}/">
{template_data}
"""
@@ -181,13 +202,13 @@ 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)
async def get_steam_resource(self, request):
tab = await get_tab("QuickAccess")
tab = await get_tab("SP")
try:
return web.Response(text=await tab.get_steam_resource(f"https://steamloopback.host/{request.match_info['path']}"), content_type="text/html")
except Exception as e:
+144 -52
View File
@@ -1,98 +1,190 @@
# Change PyInstaller files permissions
import sys
from subprocess import call
if hasattr(sys, '_MEIPASS'):
call(['chmod', '-R', '755', sys._MEIPASS])
# Full imports
from asyncio import new_event_loop, set_event_loop, sleep
from json import dumps, loads
from logging import DEBUG, INFO, basicConfig, getLogger
from os import getenv
from os import getenv, chmod, path
from traceback import format_exc
from aiohttp import ClientSession
import aiohttp_cors
# Partial imports
from aiohttp import client_exceptions, WSMsgType
from aiohttp.web import Application, Response, get, run_app, static
from aiohttp_jinja2 import setup as jinja_setup
# local modules
from browser import PluginBrowser
from helpers import (REMOTE_DEBUGGER_UNIT, csrf_middleware, get_csrf_token,
get_home_path, get_homebrew_path, get_user,
get_user_group, set_user, set_user_group,
stop_systemd_unit, start_systemd_unit)
from injector import get_gamepadui_tab, Tab, get_tabs
from loader import Loader
from settings import SettingsManager
from updater import Updater
from utilities import Utilities
# Ensure USER and GROUP vars are set first.
# TODO: This isn't the best way to do this but supports the current
# implementation. All the config load and environment setting eventually be
# moved into init or a config/loader method.
set_user()
set_user_group()
USER = get_user()
GROUP = get_user_group()
HOME_PATH = "/home/"+USER
HOMEBREW_PATH = HOME_PATH+"/homebrew"
CONFIG = {
"plugin_path": getenv("PLUGIN_PATH", "/home/deck/homebrew/plugins"),
"plugin_path": getenv("PLUGIN_PATH", HOMEBREW_PATH+"/plugins"),
"chown_plugin_path": getenv("CHOWN_PLUGIN_PATH", "1") == "1",
"server_host": getenv("SERVER_HOST", "127.0.0.1"),
"server_port": int(getenv("SERVER_PORT", "1337")),
"live_reload": getenv("LIVE_RELOAD", "1") == "1",
"log_level": {"CRITICAL": 50, "ERROR": 40, "WARNING":30, "INFO": 20, "DEBUG": 10}[getenv("LOG_LEVEL", "INFO")],
"store_url": getenv("STORE_URL", "https://beta.deckbrew.xyz")
"log_level": {"CRITICAL": 50, "ERROR": 40, "WARNING": 30, "INFO": 20, "DEBUG": 10}[
getenv("LOG_LEVEL", "INFO")
],
}
basicConfig(level=CONFIG["log_level"], format="[%(module)s][%(levelname)s]: %(message)s")
from asyncio import get_event_loop, sleep
from json import dumps, loads
from os import path
from subprocess import Popen
import aiohttp_cors
from aiohttp.web import Application, run_app, static
from aiohttp_jinja2 import setup as jinja_setup
from browser import PluginBrowser
from injector import inject_to_tab, tab_has_global_var
from loader import Loader
from utilities import Utilities
basicConfig(
level=CONFIG["log_level"],
format="[%(module)s][%(levelname)s]: %(message)s"
)
logger = getLogger("Main")
async def chown_plugin_dir(_):
Popen(["chown", "-R", "deck:deck", CONFIG["plugin_path"]])
Popen(["chmod", "-R", "555", CONFIG["plugin_path"]])
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})")
class PluginManager:
def __init__(self) -> None:
self.loop = get_event_loop()
def __init__(self, loop) -> None:
self.loop = loop
self.web_app = Application()
self.web_app.middlewares.append(csrf_middleware)
self.cors = aiohttp_cors.setup(self.web_app, defaults={
"https://steamloopback.host": aiohttp_cors.ResourceOptions(expose_headers="*",
allow_headers="*")
"https://steamloopback.host": aiohttp_cors.ResourceOptions(
expose_headers="*",
allow_headers="*",
allow_credentials=True
)
})
self.plugin_loader = Loader(self.web_app, CONFIG["plugin_path"], self.loop, CONFIG["live_reload"])
self.plugin_browser = PluginBrowser(CONFIG["plugin_path"], self.web_app, CONFIG["store_url"])
self.plugin_browser = PluginBrowser(CONFIG["plugin_path"], self.plugin_loader.plugins, self.plugin_loader)
self.settings = SettingsManager("loader", path.join(HOMEBREW_PATH, "settings"))
self.utilities = Utilities(self)
self.updater = Updater(self)
jinja_setup(self.web_app)
self.web_app.on_startup.append(self.inject_javascript)
if CONFIG["chown_plugin_path"] == True:
self.web_app.on_startup.append(chown_plugin_dir)
self.loop.create_task(self.loader_reinjector())
self.loop.create_task(self.load_plugins())
async def startup(_):
if self.settings.getSetting("cef_forward", False):
self.loop.create_task(start_systemd_unit(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(self.loader_reinjector())
self.loop.create_task(self.load_plugins())
self.web_app.on_startup.append(startup)
self.loop.set_exception_handler(self.exception_handler)
self.web_app.add_routes([get("/auth/token", self.get_auth_token)])
for route in list(self.web_app.router.routes()):
self.cors.add(route)
self.cors.add(route)
self.web_app.add_routes([static("/static", path.join(path.dirname(__file__), 'static'))])
self.web_app.add_routes([static("/legacy", path.join(path.dirname(__file__), 'legacy'))])
def exception_handler(self, loop, context):
if context["message"] == "Unclosed connection":
return
loop.default_exception_handler(context)
async def wait_for_server(self):
async with ClientSession() as web:
while True:
try:
await web.get(f"http://{CONFIG['server_host']}:{CONFIG['server_port']}")
return
except Exception as e:
await sleep(0.1)
async def get_auth_token(self, request):
return Response(text=get_csrf_token())
async def load_plugins(self):
await self.wait_for_server()
# await self.wait_for_server()
logger.debug("Loading plugins")
self.plugin_loader.import_plugins()
#await inject_to_tab("SP", "window.syncDeckyPlugins();")
# await inject_to_tab("SP", "window.syncDeckyPlugins();")
async def loader_reinjector(self):
while True:
await sleep(1)
if not await tab_has_global_var("SP", "DeckyPluginLoader"):
logger.info("Plugin loader isn't present in Steam anymore, reinjecting...")
await self.inject_javascript()
tab = None
nf = False
dc = False
while not tab:
try:
tab = await get_gamepadui_tab()
except (client_exceptions.ClientConnectorError, client_exceptions.ServerDisconnectedError):
if not dc:
logger.debug("Couldn't connect to debugger, waiting...")
dc = True
pass
except ValueError:
if not nf:
logger.debug("Couldn't find GamepadUI tab, waiting...")
nf = True
pass
if not tab:
await sleep(5)
await tab.open_websocket()
await tab.enable()
await self.inject_javascript(tab, True)
try:
async for msg in tab.listen_for_message():
# this gets spammed a lot
if msg.get("method", None) != "Page.navigatedWithinDocument":
logger.debug("Page event: " + str(msg.get("method", None)))
if msg.get("method", None) == "Page.domContentEventFired":
if not await tab.has_global_var("deckyHasLoaded", False):
await self.inject_javascript(tab)
if msg.get("method", None) == "Inspector.detached":
logger.info("CEF has requested that we detach.")
await tab.close_websocket()
break
# If this is a forceful disconnect the loop will just stop without any failure message. In this case, injector.py will handle this for us so we don't need to close the socket.
# This is because of https://github.com/aio-libs/aiohttp/blob/3ee7091b40a1bc58a8d7846e7878a77640e96996/aiohttp/client_ws.py#L321
logger.info("CEF has disconnected...")
# At this point the loop starts again and we connect to the freshly started Steam client once it is ready.
except Exception as e:
logger.error("Exception while reading page events " + format_exc())
await tab.close_websocket()
pass
# while True:
# await sleep(5)
# if not await tab.has_global_var("deckyHasLoaded", False):
# logger.info("Plugin loader isn't present in Steam anymore, reinjecting...")
# await self.inject_javascript(tab)
async def inject_javascript(self, request=None):
async def inject_javascript(self, tab: Tab, first=False, request=None):
logger.info("Loading Decky frontend!")
try:
await inject_to_tab("SP", "try{" + open(path.join(path.dirname(__file__), "./static/plugin-loader.iife.js"), "r").read() + "}catch(e){console.error(e)}", True)
if first:
if await tab.has_global_var("deckyHasLoaded", False):
tabs = await get_tabs()
for t in tabs:
if not t.title or (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(() => 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")
logger.info("Failed to inject JavaScript into tab\n" + format_exc())
pass
def run(self):
return run_app(self.web_app, host=CONFIG["server_host"], port=CONFIG["server_port"], loop=self.loop, access_log=None)
if __name__ == "__main__":
PluginManager().run()
loop = new_event_loop()
set_event_loop(loop)
PluginManager(loop).run()
+91 -35
View File
@@ -1,17 +1,21 @@
import multiprocessing
from asyncio import (Lock, get_event_loop, new_event_loop,
open_unix_connection, set_event_loop, sleep,
start_unix_server)
start_unix_server, IncompleteReadError, LimitOverrunError)
from concurrent.futures import ProcessPoolExecutor
from importlib.util import module_from_spec, spec_from_file_location
from json import dumps, load, loads
from os import path, setuid
from logging import getLogger
from traceback import format_exc
from os import path, setgid, setuid
from signal import SIGINT, signal
from sys import exit
from time import time
multiprocessing.set_start_method("fork")
BUFFER_LIMIT = 2 ** 20 # 1 MiB
class PluginWrapper:
def __init__(self, file, plugin_directory, plugin_path) -> None:
self.file = file
@@ -21,7 +25,13 @@ class PluginWrapper:
self.socket_addr = f"/tmp/plugin_socket_{time()}"
self.method_call_lock = Lock()
json = load(open(path.join(plugin_path, plugin_directory, "plugin.json"), "r"))
self.version = None
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", 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 ""
@@ -32,35 +42,64 @@ class PluginWrapper:
self.author = json["author"]
self.flags = json["flags"]
self.log = getLogger("plugin")
self.passive = not path.isfile(self.file)
def __str__(self) -> str:
return self.name
def _init(self):
signal(SIGINT, lambda s, f: exit(0))
try:
signal(SIGINT, lambda s, f: exit(0))
set_event_loop(new_event_loop())
if self.passive:
return
setuid(0 if "root" in self.flags else 1000)
spec = spec_from_file_location("_", self.file)
module = module_from_spec(spec)
spec.loader.exec_module(module)
self.Plugin = module.Plugin
set_event_loop(new_event_loop())
if self.passive:
return
setgid(0 if "root" in self.flags else 1000)
setuid(0 if "root" in self.flags else 1000)
spec = spec_from_file_location("_", self.file)
module = module_from_spec(spec)
spec.loader.exec_module(module)
self.Plugin = module.Plugin
if hasattr(self.Plugin, "_main"):
get_event_loop().create_task(self.Plugin._main(self.Plugin))
get_event_loop().create_task(self._setup_socket())
get_event_loop().run_forever()
if hasattr(self.Plugin, "_main"):
get_event_loop().create_task(self.Plugin._main(self.Plugin))
get_event_loop().create_task(self._setup_socket())
get_event_loop().run_forever()
except:
self.log.error("Failed to start " + self.name + "!\n" + format_exc())
exit(0)
async def _unload(self):
try:
self.log.info("Attempting to unload " + self.name + "\n")
if hasattr(self.Plugin, "_unload"):
await self.Plugin._unload(self.Plugin)
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)
self.socket = await start_unix_server(self._listen_for_method_call, path=self.socket_addr, limit=BUFFER_LIMIT)
async def _listen_for_method_call(self, reader, writer):
while True:
data = loads((await reader.readline()).decode("utf-8"))
line = bytearray()
while True:
try:
line.extend(await reader.readuntil())
except LimitOverrunError:
line.extend(await reader.read(reader._limit))
continue
except IncompleteReadError as err:
line.extend(err.partial)
break
else:
break
data = loads(line.decode("utf-8"))
if "stop" in data:
await self._unload()
get_event_loop().stop()
while get_event_loop().is_running():
await sleep(0)
@@ -73,17 +112,22 @@ class PluginWrapper:
d["res"] = str(e)
d["success"] = False
finally:
writer.write((dumps(d)+"\n").encode("utf-8"))
writer.write((dumps(d, ensure_ascii=False)+"\n").encode("utf-8"))
await writer.drain()
async def _open_socket_if_not_exists(self):
if not self.reader:
while True:
retries = 0
while retries < 10:
try:
self.reader, self.writer = await open_unix_connection(self.socket_addr)
break
self.reader, self.writer = await open_unix_connection(self.socket_addr, limit=BUFFER_LIMIT)
return True
except:
await sleep(0)
await sleep(2)
retries += 1
return False
else:
return True
def start(self):
if self.passive:
@@ -95,21 +139,33 @@ class PluginWrapper:
if self.passive:
return
async def _(self):
await self._open_socket_if_not_exists()
self.writer.write((dumps({"stop": True})+"\n").encode("utf-8"))
await self.writer.drain()
self.writer.close()
if await self._open_socket_if_not_exists():
self.writer.write((dumps({ "stop": True }, ensure_ascii=False)+"\n").encode("utf-8"))
await self.writer.drain()
self.writer.close()
get_event_loop().create_task(_(self))
async def execute_method(self, method_name, kwargs):
if self.passive:
raise RuntimeError("This plugin is passive (aka does not implement main.py)")
async with self.method_call_lock:
await self._open_socket_if_not_exists()
self.writer.write(
(dumps({"method": method_name, "args": kwargs})+"\n").encode("utf-8"))
await self.writer.drain()
res = loads((await self.reader.readline()).decode("utf-8"))
if not res["success"]:
raise Exception(res["res"])
return res["res"]
if await self._open_socket_if_not_exists():
self.writer.write(
(dumps({ "method": method_name, "args": kwargs }, ensure_ascii=False) + "\n").encode("utf-8"))
await self.writer.drain()
line = bytearray()
while True:
try:
line.extend(await self.reader.readuntil())
except LimitOverrunError:
line.extend(await self.reader.read(self.reader._limit))
continue
except IncompleteReadError as err:
line.extend(err.partial)
break
else:
break
res = loads(line.decode("utf-8"))
if not res["success"]:
raise Exception(res["res"])
return res["res"]
+60
View File
@@ -0,0 +1,60 @@
from json import dump, load
from os import mkdir, path, listdir, rename
from shutil import chown
from helpers import get_home_path, get_homebrew_path, get_user, set_user, get_user_owner
class SettingsManager:
def __init__(self, name, settings_directory = None) -> None:
set_user()
USER = get_user()
wrong_dir = get_homebrew_path(get_home_path(USER))
if settings_directory == None:
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, USER, USER)
#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 get_user_owner(settings_directory) != USER:
chown(settings_directory, USER, USER)
self.settings = {}
try:
open(self.path, "x", encoding="utf-8")
except FileExistsError as e:
self.read()
pass
def read(self):
try:
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+", encoding="utf-8") as file:
dump(self.settings, file, indent=4, ensure_ascii=False)
def getSetting(self, key, default):
return self.settings.get(key, default)
def setSetting(self, key, value):
self.settings[key] = value
self.commit()
+205
View File
@@ -0,0 +1,205 @@
import os
import shutil
import uuid
from asyncio import sleep
from ensurepip import version
from json.decoder import JSONDecodeError
from logging import getLogger
from os import getcwd, path, remove
from subprocess import call
from aiohttp import ClientSession, web
import helpers
from injector import get_gamepadui_tab, inject_to_tab
from settings import SettingsManager
logger = getLogger("Updater")
class Updater:
def __init__(self, context) -> None:
self.context = context
self.settings = self.context.settings
# Exposes updater methods to frontend
self.updater_methods = {
"get_branch": self._get_branch,
"get_version": self.get_version,
"do_update": self.do_update,
"do_restart": self.do_restart,
"check_for_updates": self.check_for_updates
}
self.remoteVer = None
self.allRemoteVers = None
try:
logger.info(getcwd())
with open(path.join(getcwd(), ".loader.version"), "r", encoding="utf-8") as version_file:
self.localVer = version_file.readline().replace("\n", "")
except:
self.localVer = False
try:
self.currentBranch = self.get_branch(self.context.settings)
except:
self.currentBranch = 0
logger.error("Current branch could not be determined, defaulting to \"Stable\"")
if context:
context.web_app.add_routes([
web.post("/updater/{method_name}", self._handle_server_method_call)
])
context.loop.create_task(self.version_reloader())
async def _handle_server_method_call(self, request):
method_name = request.match_info["method_name"]
try:
args = await request.json()
except JSONDecodeError:
args = {}
res = {}
try:
r = await self.updater_methods[method_name](**args)
res["result"] = r
res["success"] = True
except Exception as e:
res["result"] = str(e)
res["success"] = False
return web.json_response(res)
def get_branch(self, manager: SettingsManager):
ver = manager.getSetting("branch", -1)
logger.debug("current branch: %i" % ver)
if ver == -1:
logger.info("Current branch is not set, determining branch from version...")
if self.localVer.startswith("v") and self.localVer.find("-pre"):
logger.info("Current version determined to be pre-release")
return 1
else:
logger.info("Current version determined to be stable")
return 0
return ver
async def _get_branch(self, manager: SettingsManager):
return self.get_branch(manager)
# retrieve relevant service file's url for each branch
def get_service_url(self):
logger.debug("Getting service URL")
branch = self.get_branch(self.context.settings)
match branch:
case 0:
url = "https://raw.githubusercontent.com/SteamDeckHomebrew/decky-loader/main/dist/plugin_loader-release.service"
case 1 | 2:
url = "https://raw.githubusercontent.com/SteamDeckHomebrew/decky-loader/main/dist/plugin_loader-prerelease.service"
case _:
logger.error("You have an invalid branch set... Defaulting to prerelease service, please send the logs to the devs!")
url = "https://raw.githubusercontent.com/SteamDeckHomebrew/decky-loader/main/dist/plugin_loader-prerelease.service"
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}
async def check_for_updates(self):
logger.debug("checking for updates")
selectedBranch = self.get_branch(self.context.settings)
async with ClientSession() as web:
async with web.request("GET", "https://api.github.com/repos/SteamDeckHomebrew/decky-loader/releases", ssl=helpers.get_ssl_context()) as res:
remoteVersions = await res.json()
self.allRemoteVers = remoteVersions
logger.debug("determining release type to find, branch is %i" % selectedBranch)
if selectedBranch == 0:
logger.debug("release type: release")
self.remoteVer = next(filter(lambda ver: ver["tag_name"].startswith("v") and not ver["prerelease"] and ver["tag_name"], remoteVersions), None)
elif selectedBranch == 1:
logger.debug("release type: pre-release")
self.remoteVer = next(filter(lambda ver: ver["prerelease"] and ver["tag_name"].startswith("v") and ver["tag_name"].find("-pre"), remoteVersions), None)
else:
logger.error("release type: NOT FOUND")
raise ValueError("no valid branch found")
logger.info("Updated remote version information")
tab = await get_gamepadui_tab()
await tab.evaluate_js(f"window.DeckyPluginLoader.notifyUpdates()", False, True, False)
return await self.get_version()
async def version_reloader(self):
await sleep(30)
while True:
try:
await self.check_for_updates()
except:
pass
await sleep(60 * 60 * 6) # 6 hours
async def do_update(self):
logger.debug("Starting update.")
version = self.remoteVer["tag_name"]
download_url = self.remoteVer["assets"][0]["browser_download_url"]
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", encoding="utf-8") 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", 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("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:
progress = 0
raw = 0
async for c in res.content.iter_chunked(512):
out.write(c)
raw += len(c)
new_progress = round((raw / total) * 100)
if progress != new_progress:
self.context.loop.create_task(tab.evaluate_js(f"window.DeckyUpdater.updateProgress({new_progress})", False, False, False))
progress = new_progress
with open(path.join(getcwd(), ".loader.version"), "w", encoding="utf-8") as out:
out.write(version)
call(['chmod', '+x', path.join(getcwd(), "PluginLoader")])
logger.info("Updated loader installation.")
await tab.evaluate_js("window.DeckyUpdater.finish()", False, False)
await self.do_restart()
await tab.close_websocket()
async def do_restart(self):
call(["systemctl", "daemon-reload"])
call(["systemctl", "restart", "plugin_loader"])
+143 -12
View File
@@ -1,9 +1,15 @@
import uuid
import os
from json.decoder import JSONDecodeError
from traceback import format_exc
from asyncio import sleep, start_server, gather, open_connection
from aiohttp import ClientSession, web
from injector import inject_to_tab
from logging import getLogger
from injector import inject_to_tab, get_gamepadui_tab
import helpers
import subprocess
class Utilities:
@@ -12,13 +18,28 @@ class Utilities:
self.util_methods = {
"ping": self.ping,
"http_request": self.http_request,
"install_plugin": self.install_plugin,
"cancel_plugin_install": self.cancel_plugin_install,
"confirm_plugin_install": self.confirm_plugin_install,
"uninstall_plugin": self.uninstall_plugin,
"execute_in_tab": self.execute_in_tab,
"inject_css_into_tab": self.inject_css_into_tab,
"remove_css_from_tab": self.remove_css_from_tab
"remove_css_from_tab": self.remove_css_from_tab,
"allow_remote_debugging": self.allow_remote_debugging,
"disallow_remote_debugging": self.disallow_remote_debugging,
"set_setting": self.set_setting,
"get_setting": self.get_setting,
"filepicker_ls": self.filepicker_ls,
"disable_rdt": self.disable_rdt,
"enable_rdt": self.enable_rdt
}
self.logger = getLogger("Utilities")
self.rdt_proxy_server = None
self.rdt_script_id = None
self.rdt_proxy_task = None
if context:
context.web_app.add_routes([
web.post("/methods/{method_name}", self._handle_server_method_call)
@@ -40,20 +61,32 @@ class Utilities:
res["success"] = False
return web.json_response(res)
async def install_plugin(self, artifact="", name="No name", version="dev", hash=False):
return await self.context.plugin_browser.request_plugin_install(
artifact=artifact,
name=name,
version=version,
hash=hash
)
async def confirm_plugin_install(self, request_id):
return await self.context.plugin_browser.confirm_plugin_install(request_id)
def cancel_plugin_install(self, request_id):
return self.context.plugin_browser.cancel_plugin_install(request_id)
async def uninstall_plugin(self, name):
return await self.context.plugin_browser.uninstall_plugin(name)
async def http_request(self, method="", url="", **kwargs):
async with ClientSession() as web:
async with web.request(method, url, **kwargs) as res:
return {
"status": res.status,
"headers": dict(res.headers),
"body": await res.text()
}
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": text
}
async def ping(self, **kwargs):
return "pong"
@@ -69,12 +102,12 @@ class Utilities:
return {
"success": True,
"result" : result["result"]["result"].get("value")
"result": result["result"]["result"].get("value")
}
except Exception as e:
return {
"success": False,
"result": e
"success": False,
"result": e
}
async def inject_css_into_tab(self, tab, style):
@@ -99,7 +132,7 @@ class Utilities:
return {
"success": True,
"result" : css_id
"result": css_id
}
except Exception as e:
return {
@@ -133,3 +166,101 @@ class Utilities:
"success": False,
"result": e
}
async def get_setting(self, key, default):
return self.context.settings.getSetting(key, default)
async def set_setting(self, key, value):
return self.context.settings.setSetting(key, value)
async def allow_remote_debugging(self):
await helpers.start_systemd_unit(helpers.REMOTE_DEBUGGER_UNIT)
return True
async def disallow_remote_debugging(self):
await helpers.stop_systemd_unit(helpers.REMOTE_DEBUGGER_UNIT)
return True
async def filepicker_ls(self, path, include_files=True):
# def sorter(file): # Modification time
# if os.path.isdir(os.path.join(path, file)) or os.path.isfile(os.path.join(path, file)):
# return os.path.getmtime(os.path.join(path, file))
# return 0
# file_names = sorted(os.listdir(path), key=sorter, reverse=True) # TODO provide more sort options
file_names = sorted(os.listdir(path)) # Alphabetical
files = []
for file in file_names:
full_path = os.path.join(path, file)
is_dir = os.path.isdir(full_path)
if is_dir or include_files:
files.append({
"isdir": is_dir,
"name": file,
"realpath": os.path.realpath(full_path)
})
return {
"realpath": os.path.realpath(path),
"files": files
}
# Based on https://stackoverflow.com/a/46422554/13174603
def start_rdt_proxy(self, ip, port):
async def pipe(reader, writer):
try:
while not reader.at_eof():
writer.write(await reader.read(2048))
finally:
writer.close()
async def handle_client(local_reader, local_writer):
try:
remote_reader, remote_writer = await open_connection(
ip, port)
pipe1 = pipe(local_reader, remote_writer)
pipe2 = pipe(remote_reader, local_writer)
await gather(pipe1, pipe2)
finally:
local_writer.close()
self.rdt_proxy_server = start_server(handle_client, "127.0.0.1", port)
self.rdt_proxy_task = self.context.loop.create_task(self.rdt_proxy_server)
def stop_rdt_proxy(self):
if self.rdt_proxy_server:
self.rdt_proxy_server.close()
self.rdt_proxy_task.cancel()
async def enable_rdt(self):
# TODO un-hardcode port
try:
self.stop_rdt_proxy()
ip = self.context.settings.getSetting("developer.rdt.ip", None)
if ip != None:
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())
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.
result = await tab.reload_and_evaluate(script)
self.logger.info(result)
except Exception:
self.logger.error("Failed to connect to React DevTools")
self.logger.error(format_exc())
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)
self.logger.info("React DevTools disabled")
+105 -52
View File
@@ -4,6 +4,8 @@
## This script defaults to port 22 unless otherwise specified, and cannot run without a sudo password or LAN IP.
## You will need to specify the path to the ssh key if using key connection exclusively.
## TODO: document latest changes to wiki
## Pre-parse arugments for ease of use
CLONEFOLDER=${1:-""}
INSTALLFOLDER=${2:-""}
@@ -11,9 +13,13 @@ DECKIP=${3:-""}
SSHPORT=${4:-""}
PASSWORD=${5:-""}
SSHKEYLOC=${6:-""}
LOADERBRANCH=${7:-""}
LIBRARYBRANCH=${8:-""}
TEMPLATEBRANCH=${9:-""}
LATEST=${10:-""}
## gather options into an array
OPTIONSARRAY=("$CLONEFOLDER" $INSTALLFOLDER "$DECKIP" "$SSHPORT" "$PASSWORD" "$SSHKEYLOC")
OPTIONSARRAY=("$CLONEFOLDER" "$INSTALLFOLDER" "$DECKIP" "$SSHPORT" "$PASSWORD" "$SSHKEYLOC" "$LOADERBRANCH" "$LIBRARYBRANCH" "$TEMPLATEBRANCH" "$LATEST")
## iterate through options array to check their presence
count=0
@@ -28,19 +34,21 @@ setfolder() {
local DEFAULT="git"
elif [[ "$2" == "install" ]]; then
local ACTION="install"
local DEFAULT="loaderdev"
local DEFAULT="dev"
fi
printf "Enter the directory in /home/user to ${ACTION} to.\n"
printf "Example: if your home directory is /home/user you would type: ${DEFAULT}\n"
printf "The ${ACTION} directory would be: ${HOME}/${DEFAULT}\n"
if [[ "$ACTION" == "clone" ]]; then
printf "Enter the directory in /home/user/ to ${ACTION} to.\n"
printf "The ${ACTION} directory would be: ${HOME}/${DEFAULT}\n"
read -p "Enter your ${ACTION} directory: " CLONEFOLDER
if ! [[ "$CLONEFOLDER" =~ ^[[:alnum:]]+$ ]]; then
printf "Folder name not provided. Using default, '${DEFAULT}'.\n"
CLONEFOLDER="${DEFAULT}"
fi
elif [[ "$ACTION" == "install" ]]; then
printf "Enter the directory in /home/deck/homebrew to ${ACTION} pluginloader to.\n"
printf "The ${ACTION} directory would be: /home/deck/homebrew/${DEFAULT}/pluginloader\n"
printf "It is highly recommended that you use the default folder path seen above, just press enter at the next prompt.\n"
read -p "Enter your ${ACTION} directory: " INSTALLFOLDER
if ! [[ "$INSTALLFOLDER" =~ ^[[:alnum:]]+$ ]]; then
printf "Folder name not provided. Using default, '${DEFAULT}'.\n"
@@ -106,47 +114,81 @@ clonefromto() {
# printf "repo=$1\n"
# printf "outdir=$2\n"
# printf "branch=$3\n"
if [[ -z $3 ]]; then
BRANCH=""
else
BRANCH="-b $3"
fi
git clone $1 $2 $BRANCH &> '/dev/null'
printf "Repository: $1\n"
git clone $1 $2 &> '/dev/null'
CODE=$?
# printf "CODE=${CODE}"
if [[ $CODE -eq 128 ]]; then
cd $2
git fetch &> '/dev/null'
git fetch --all &> '/dev/null'
fi
if [[ -z $3 ]]; then
printf "Enter the desired branch for repository "$1" :\n"
local OUT="$(git branch -r | sed '/\/HEAD/d')"
# $OUT="$($OUT > )"
printf "$OUT\nbranch: "
read BRANCH
else
printf "on branch: $3\n"
BRANCH="$3"
fi
if ! [[ -z ${BRANCH} ]]; then
git checkout $BRANCH &> '/dev/null'
fi
if [[ ${LATEST} == "true" ]]; then
git pull --all
elif [[ ${LATEST} == "true" ]]; then
printf "Assuming user not pulling latest commits.\n"
else
printf "Pull latest commits? (y/N): "
read PULL
case ${PULL:0:1} in
y|Y )
printf "Pulling latest commits.\n"
git pull --all
;;
* )
printf "Not pulling latest commits.\n"
;;
esac
if ! [[ "$PULL" =~ ^[[:alnum:]]+$ ]]; then
printf "Assuming user not pulling latest commits.\n"
fi
fi
}
npmtransbundle() {
pnpmtransbundle() {
cd $1
if [[ "$2" == "library" ]]; then
npm install --quiet &> '/dev/null'
npm run build --quiet &> '/dev/null'
sudo npm link --quiet &> '/dev/null'
elif [[ "$2" == "frontend" ]] || [[ "$2" == "template" ]]; then
npm install --quiet &> '/dev/null'
npm link decky-frontend-lib --quiet &> '/dev/null'
npm run build --quiet &> '/dev/null'
elif [[ "$2" == "frontend" ]]; then
pnpm i &> '/dev/null'
pnpm run build &> '/dev/null'
elif [[ "$2" == "template" ]]; then
pnpm i &> '/dev/null'
pnpm run build &> '/dev/null'
fi
}
printf "Installing Steam Deck Plugin Loader contributor (for Steam Deck)...\n"
if ! [[ $count -gt 9 ]] ; then
printf "Installing Steam Deck Plugin Loader contributor/developer (for Steam Deck)...\n"
printf "THIS SCRIPT ASSUMES YOU ARE RUNNING IT ON A PC, NOT THE DECK!
Not planning to contribute to PluginLoader?
If so, you should not be using this script.\n
If you have a release/nightly installed this script will disable it.\n"
printf "THIS SCRIPT ASSUMES YOU ARE RUNNING IT ON A PC, NOT THE DECK!
Not planning to contribute to or develop for PluginLoader?
If so, you should not be using this script.\n
If you have a release/nightly installed this script will disable it.\n"
printf "This script requires you to have nodejs installed. (If nodejs doesn't bundle npm on your OS/distro, then npm is required as well).\n"
# [[ $count -gt 0 ]] || read -p "Press any key to continue"
printf "This script requires you to have nodejs installed. (If nodejs doesn't bundle npm on your OS/distro, then npm is required as well).\n"
fi
if ! [[ $count -gt 0 ]] ; then
read -p "Press any key to continue"
fi
printf "\n"
## User chooses preffered clone & install directories
if [[ "$CLONEFOLDER" == "" ]]; then
@@ -158,7 +200,7 @@ if [[ "$INSTALLFOLDER" == "" ]]; then
fi
CLONEDIR="$HOME/$CLONEFOLDER"
INSTALLDIR="/home/deck/$INSTALLFOLDER"
INSTALLDIR="/home/deck/homebrew/$INSTALLFOLDER"
## Input ip address, port, password and sshkey
@@ -208,7 +250,7 @@ fi
## Create folder structure
printf "\nCloning git repositories.\n"
printf "Cloning git repositories.\n"
mkdir -p ${CLONEDIR} &> '/dev/null'
@@ -217,61 +259,72 @@ mkdir -p ${CLONEDIR} &> '/dev/null'
# rm -r ${CLONEDIR}/pluginlibrary
# rm -r ${CLONEDIR}/plugintemplate
clonefromto "https://github.com/SteamDeckHomebrew/PluginLoader" ${CLONEDIR}/pluginloader react-frontend-plugins
clonefromto "https://github.com/SteamDeckHomebrew/PluginLoader" ${CLONEDIR}/pluginloader "$LOADERBRANCH"
clonefromto "https://github.com/SteamDeckHomebrew/decky-frontend-lib" ${CLONEDIR}/pluginlibrary
clonefromto "https://github.com/SteamDeckHomebrew/decky-frontend-lib" ${CLONEDIR}/pluginlibrary "$LIBRARYBRANCH"
clonefromto "https://github.com/SteamDeckHomebrew/decky-plugin-template" ${CLONEDIR}/plugintemplate
clonefromto "https://github.com/SteamDeckHomebrew/decky-plugin-template" ${CLONEDIR}/plugintemplate "$TEMPLATEBRANCH"
## install python dependencies to deck
printf "\nInstalling python dependencies.\n"
rsync -azp --rsh="ssh -p $SSHPORT $IDENINVOC" ${CLONEDIR}/pluginloader/requirements.txt deck@${DECKIP}:${INSTALLDIR}/pluginloader/requirements.txt &> '/dev/null'
ssh deck@${DECKIP} -p ${SSHPORT} ${IDENINVOC} "python -m ensurepip && python -m pip install --upgrade pip && python -m pip install --upgrade setuptools && python -m pip install -r $INSTALLDIR/pluginloader/requirements.txt" &> '/dev/null'
## Transpile and bundle typescript
type npm &> '/dev/null'
[ "$UID" -eq 0 ] || printf "Input password to proceed with install.\n"
NPMLIVES=$?
sudo npm install -g pnpm &> '/dev/null'
if ! [[ "$NPMLIVES" -eq 0 ]]; then
printf "npm does not to be installed, exiting.\n"
type pnpm &> '/dev/null'
PNPMLIVES=$?
if ! [[ "$PNPMLIVES" -eq 0 ]]; then
printf "pnpm does not appear to be installed, exiting.\n"
exit 1
fi
[ "$UID" -eq 0 ] || printf "Input password to install typscript compiler.\n"
## TODO: add a way of verifying if tsc is installed and to skip this step if it is
sudo npm install --quiet -g tsc &> '/dev/null'
printf "Transpiling and bundling typescript.\n"
npmtransbundle ${CLONEDIR}/pluginlibrary/ "library"
pnpmtransbundle ${CLONEDIR}/pluginlibrary/ "library"
npmtransbundle ${CLONEDIR}/pluginloader/frontend "frontend"
pnpmtransbundle ${CLONEDIR}/pluginloader/frontend "frontend"
npmtransbundle ${CLONEDIR}/plugintemplate "template"
pnpmtransbundle ${CLONEDIR}/plugintemplate "template"
## Transfer relevant files to deck
printf "Copying relevant files to install directory\n\n"
ssh deck@${DECKIP} -p ${SSHPORT} ${IDENINVOC} "mkdir -p $INSTALLDIR/pluginloader && mkdir -p $INSTALLDIR/plugins" &> '/dev/null'
### copy files for PluginLoader
rsync -avzp --mkpath --rsh="ssh -p ${SSHPORT} ${IDENINVOC}" --exclude='.git/' --exclude='node_modules' --exclude="package-lock.json" --exclude=='frontend' --exclude="*dist*" --exclude="*contrib*" --delete ${CLONEDIR}/pluginloader/* deck@${DECKIP}:${INSTALLDIR}/pluginloader/ &> '/dev/null'
rsync -avzp --rsh="ssh -p $SSHPORT $IDENINVOC" --exclude='.git/' --exclude='.github/' --exclude='.vscode/' --exclude='frontend/' --exclude='dist/' --exclude='contrib/' --exclude='*.log' --exclude='requirements.txt' --exclude='backend/__pycache__/' --exclude='.gitignore' --delete ${CLONEDIR}/pluginloader/* deck@${DECKIP}:${INSTALLDIR}/pluginloader &> '/dev/null'
if ! [[ $? -eq 0 ]]; then
printf "Error occurred when copying ${CLONEDIR}/pluginloader/ to ${INSTALLDIR}/pluginloader/\n"
printf "Error occurred when copying $CLONEDIR/pluginloader/ to $INSTALLDIR/pluginloader/\n"
printf "Check that your Steam Deck is active, ssh is enabled and running and is accepting connections.\n"
exit 1
fi
### copy files for PluginLoader template
rsync -avzp --mkpath --rsh="ssh -p ${SSHPORT} ${IDENINVOC}" --exclude='.git/' --exclude='node_modules' --exclude="package-lock.json" --delete ${CLONEDIR}/plugintemplate deck@${DECKIP}:${INSTALLDIR}/plugins &> '/dev/null'
### copy files for plugin template
rsync -avzp --rsh="ssh -p $SSHPORT $IDENINVOC" --exclude='.git/' --exclude='.github/' --exclude='.vscode/' --exclude='node_modules/' --exclude='src/' --exclude='*.log' --exclude='.gitignore' --exclude='pnpm-lock.yaml' --exclude='package.json' --exclude='rollup.config.js' --exclude='tsconfig.json' --delete ${CLONEDIR}/plugintemplate deck@${DECKIP}:${INSTALLDIR}/plugins &> '/dev/null'
if ! [[ $? -eq 0 ]]; then
printf "Error occurred when copying ${CLONEDIR}/plugintemplate to ${INSTALLDIR}/plugins\n"
printf "Error occurred when copying $CLONEDIR/plugintemplate to $INSTALLDIR/plugins\n"
exit 1
fi
## TODO: direct contributors to wiki for this info?
printf "Run these commands to deploy your local changes to the deck:\n"
printf "'rsync -avzp --mkpath --rsh=""\"ssh -p ${SSHPORT} ${IDENINVOC}\""" --exclude='.git/' --exclude='node_modules' --exclude='package-lock.json' --delete ${CLONEDIR}/pluginname deck@${DECKIP}:${INSTALLDIR}/plugins'\n"
printf "'rsync -avzp --mkpath --rsh=""\"ssh -p ${SSHPORT} ${IDENINVOC}\""" --exclude='.git/' --exclude='node_modules' --exclude='package-lock.json' --exclude=='frontend' --exclude='*dist*' --exclude='*contrib*' --delete ${CLONEDIR}/pluginloader/* deck@${DECKIP}:${INSTALLDIR}/pluginloader/'\n"
printf "'rsync -avzp --mkpath --rsh=""\"ssh -p $SSHPORT $IDENINVOC\""" --exclude='.git/' --exclude='.github/' --exclude='.vscode/' --exclude='frontend/' --exclude='dist/' --exclude='contrib/' --exclude='*.log' --exclude='requirements.txt' --exclude='backend/__pycache__/' --exclude='.gitignore' --delete $CLONEDIR/pluginloader/* deck@$DECKIP:$INSTALLDIR/pluginloader/'\n"
printf "'rsync -avzp --mkpath --rsh=""\"ssh -p $SSHPORT $IDENINVOC\""" --exclude='.git/' --exclude='.github/' --exclude='.vscode/' --exclude='node_modules/' --exclude='src/' --exclude='*.log' --exclude='.gitignore' --exclude='package-lock.json' --delete $CLONEDIR/pluginname deck@$DECKIP:$INSTALLDIR/plugins'\n\n"
printf "Run in console or in a script this command to run your development version:\n'ssh deck@${DECKIP} -p 22 ${IDENINVOC} 'export PLUGIN_PATH=${INSTALLDIR}/plugins; export CHOWN_PLUGIN_PATH=0; echo 'steam' | sudo -SE python3 ${INSTALLDIR}/pluginloader/backend/main.py'\n"
printf "Run in console or in a script this command to run your development version:\n'ssh deck@$DECKIP -p $SSHPORT $IDENINVOC 'export PLUGIN_PATH=$INSTALLDIR/plugins; export CHOWN_PLUGIN_PATH=0; echo 'steam' | sudo -SE python3 $INSTALLDIR/pluginloader/backend/main.py'\n"
## Disable Releases versions if they exist
@@ -279,4 +332,4 @@ printf "Run in console or in a script this command to run your development versi
printf "Connecting via ssh to disable any PluginLoader release versions.\n"
printf "Script will exit after this. All done!\n"
ssh deck@$DECKIP -p $SSHPORT $IDENINVOC "printf ${PASSWORD} | sudo -S systemctl disable --now plugin_loader; echo $?"
ssh deck@${DECKIP} -p ${SSHPORT} ${IDENINVOC} "printf $PASSWORD | sudo -S systemctl disable --now plugin_loader; echo $?" &> '/dev/null'
+99 -57
View File
@@ -2,87 +2,115 @@
## Pre-parse arugments for ease of use
CLONEFOLDER=${1:-""}
LOADERBRANCH=${2:-""}
LIBRARYBRANCH=${3:-""}
TEMPLATEBRANCH=${4:-""}
LATEST=${5:-""}
setfolder() {
if [[ "$2" == "clone" ]]; then
local ACTION="clone"
local DEFAULT="git"
elif [[ "$2" == "install" ]]; then
local ACTION="install"
local DEFAULT="loaderdev"
fi
## gather options into an array
OPTIONSARRAY=("$CLONEFOLDER" "$LOADERBRANCH" "$LIBRARYBRANCH" "$TEMPLATEBRANCH" "$LATEST")
printf "Enter the directory in /home/user to ${ACTION} to.\n"
printf "Example: if your home directory is /home/user you would type: ${DEFAULT}\n"
printf "The ${ACTION} directory would be: ${HOME}/${DEFAULT}\n"
if [[ "$ACTION" == "clone" ]]; then
read -p "Enter your ${ACTION} directory: " CLONEFOLDER
if ! [[ "$CLONEFOLDER" =~ ^[[:alnum:]]+$ ]]; then
printf "Folder name not provided. Using default, '${DEFAULT}'.\n"
CLONEFOLDER="${DEFAULT}"
fi
elif [[ "$ACTION" == "install" ]]; then
read -p "Enter your ${ACTION} directory: " INSTALLFOLDER
if ! [[ "$INSTALLFOLDER" =~ ^[[:alnum:]]+$ ]]; then
printf "Folder name not provided. Using default, '${DEFAULT}'.\n"
INSTALLFOLDER="${DEFAULT}"
fi
else
printf "Folder type could not be determined, exiting\n"
exit 1
fi
}
## iterate through options array to check their presence
count=0
for OPTION in ${OPTIONSARRAY[@]}; do
! [[ "$OPTION" == "" ]] && count=$(($count+1))
# printf "OPTION=$OPTION\n"
done
clonefromto() {
# printf "repo=$1\n"
# printf "outdir=$2\n"
# printf "branch=$3\n"
if [[ -z $3 ]]; then
BRANCH=""
else
BRANCH="-b $3"
fi
git clone $1 $2 $BRANCH &> '/dev/null'
printf "Repository: $1\n"
git clone $1 $2 &> '/dev/null'
CODE=$?
# printf "CODE=${CODE}"
if [[ $CODE -eq 128 ]]; then
cd $2
git fetch &> '/dev/null'
git fetch --all &> '/dev/null'
fi
if [[ -z $3 ]]; then
printf "Enter the desired branch for repository "$1" :\n"
local OUT="$(git branch -r | sed '/\/HEAD/d')"
# $OUT="$($OUT > )"
printf "$OUT\nbranch: "
read BRANCH
else
printf "on branch: $3\n"
BRANCH="$3"
fi
if ! [[ -z ${BRANCH} ]]; then
git checkout $BRANCH &> '/dev/null'
fi
if [[ ${LATEST} == "true" ]]; then
git pull --all
elif [[ ${LATEST} == "true" ]]; then
printf "Assuming user not pulling latest commits.\n"
else
printf "Pull latest commits? (y/N): "
read PULL
case ${PULL:0:1} in
y|Y )
printf "Pulling latest commits.\n"
git pull --all
;;
* )
printf "Not pulling latest commits.\n"
;;
esac
if ! [[ "$PULL" =~ ^[[:alnum:]]+$ ]]; then
printf "Assuming user not pulling latest commits.\n"
fi
fi
}
npmtransbundle() {
pnpmtransbundle() {
cd $1
if [[ "$2" == "library" ]]; then
npm install --quiet &> '/dev/null'
npm run build --quiet &> '/dev/null'
sudo npm link --quiet &> '/dev/null'
elif [[ "$2" == "frontend" ]] || [[ "$2" == "template" ]]; then
npm install --quiet &> '/dev/null'
npm link decky-frontend-lib --quiet &> '/dev/null'
npm run build --quiet &> '/dev/null'
elif [[ "$2" == "frontend" ]]; then
pnpm i &> '/dev/null'
pnpm run build &> '/dev/null'
elif [[ "$2" == "template" ]]; then
pnpm i &> '/dev/null'
pnpm run build &> '/dev/null'
fi
}
printf "Installing Steam Deck Plugin Loader contributor (no Steam Deck)..."
printf "\nTHIS SCRIPT ASSUMES YOU ARE RUNNING IT ON A PC, NOT THE DECK!
If you are not planning to contribute to PluginLoader then you should not be using this script.\n"
if ! [[ $count -gt 4 ]] ; then
printf "Installing Steam Deck Plugin Loader contributor/developer (no Steam Deck)..."
printf "\nThis script requires you to have nodejs installed. (If nodejs doesn't bundle npm on your OS/distro, then npm is required as well).\n"
printf "\nTHIS SCRIPT ASSUMES YOU ARE RUNNING IT ON A PC, NOT THE DECK!
Not planning to contribute to or develop for PluginLoader?
Then you should not be using this script.\n"
if [[ -z $1 ]]; then
printf "\nThis script requires you to have nodejs installed. (If nodejs doesn't bundle npm on your OS/distro, then npm is required as well).\n"
fi
if ! [[ $count -gt 0 ]] ; then
read -p "Press any key to continue"
fi
printf "\n"
if [[ "$CLONEFOLDER" == "" ]]; then
setfolder "$CLONEFOLDER" "clone"
printf "Enter the directory in /home/user/ to clone to.\n"
printf "The clone directory would be: ${HOME}/git \n"
read -p "Enter your clone directory: " CLONEFOLDER
if ! [[ "$CLONEFOLDER" =~ ^[[:alnum:]]+$ ]]; then
printf "Folder name not provided. Using default, '${DEFAULT}'.\n"
CLONEFOLDER="${DEFAULT}"
fi
fi
CLONEDIR="$HOME/$CLONEFOLDER"
## Create folder structure
printf "\nCloning git repositories.\n"
printf "Cloning git repositories.\n"
mkdir -p ${CLONEDIR} &> '/dev/null'
@@ -91,33 +119,47 @@ mkdir -p ${CLONEDIR} &> '/dev/null'
# rm -r ${CLONEDIR}/pluginlibrary
# rm -r ${CLONEDIR}/plugintemplate
clonefromto "https://github.com/SteamDeckHomebrew/PluginLoader" ${CLONEDIR}/pluginloader react-frontend-plugins
clonefromto "https://github.com/SteamDeckHomebrew/PluginLoader" ${CLONEDIR}/pluginloader "$LOADERBRANCH"
clonefromto "https://github.com/SteamDeckHomebrew/decky-frontend-lib" ${CLONEDIR}/pluginlibrary
clonefromto "https://github.com/SteamDeckHomebrew/decky-frontend-lib" ${CLONEDIR}/pluginlibrary "$LIBRARYBRANCH"
clonefromto "https://github.com/SteamDeckHomebrew/decky-plugin-template" ${CLONEDIR}/plugintemplate
clonefromto "https://github.com/SteamDeckHomebrew/decky-plugin-template" ${CLONEDIR}/plugintemplate "$TEMPLATEBRANCH"
## install python dependencies (maybe use venv?)
python -m pip install -r ${CLONEDIR}/pluginloader/requirements.txt &> '/dev/null'
## Transpile and bundle typescript
[ "$UID" -eq 0 ] || printf "Input password to proceed with install.\n"
type npm &> '/dev/null'
NPMLIVES=$?
if ! [[ "$NPMLIVES" -eq 0 ]]; then
printf "npm needs to be installed, exiting.\n"
if ! [[ "$PNPMLIVES" -eq 0 ]]; then
printf "npm does not appear to be installed, exiting.\n"
exit 1
fi
[ "$UID" -eq 0 ] || printf "Input password to install typscript compiler.\n"
sudo npm install -g pnpm &> '/dev/null'
sudo npm install --quiet -g tsc &> '/dev/null'
type pnpm &> '/dev/null'
PNPMLIVES=$?
if ! [[ "$PNPMLIVES" -eq 0 ]]; then
printf "pnpm does not appear to be installed, exiting.\n"
exit 1
fi
printf "Transpiling and bundling typescript.\n"
npmtransbundle ${CLONEDIR}/pluginlibrary/ "library"
pnpmtransbundle ${CLONEDIR}/pluginlibrary/ "library"
npmtransbundle ${CLONEDIR}/pluginloader/frontend "frontend"
pnpmtransbundle ${CLONEDIR}/pluginloader/frontend "frontend"
npmtransbundle ${CLONEDIR}/plugintemplate "template"
pnpmtransbundle ${CLONEDIR}/plugintemplate "template"
printf "Plugin Loader is located at '${CLONEDIR}/pluginloader/'.\n"
-50
View File
@@ -1,50 +0,0 @@
#!/bin/sh
[ "$UID" -eq 0 ] || exec sudo "$0" "$@"
echo "Installing Steam Deck Plugin Loader nightly..."
HOMEBREW_FOLDER=/home/deck/homebrew
# Create folder structure
rm -rf ${HOMEBREW_FOLDER}/services
sudo -u deck mkdir -p ${HOMEBREW_FOLDER}/services
sudo -u deck mkdir -p ${HOMEBREW_FOLDER}/plugins
# Download latest nightly build and install it
rm -rf /tmp/plugin_loader
mkdir -p /tmp/plugin_loader
curl -L https://nightly.link/SteamDeckHomebrew/PluginLoader/workflows/build/main/Plugin%20Loader.zip --output /tmp/plugin_loader/PluginLoader.zip
unzip /tmp/plugin_loader/PluginLoader.zip -d /tmp/plugin_loader
cp /tmp/plugin_loader/PluginLoader ${HOMEBREW_FOLDER}/services/PluginLoader
rm -rf /tmp/plugin_loader
chmod +x ${HOMEBREW_FOLDER}/services/PluginLoader
systemctl --user stop plugin_loader 2> /dev/null
systemctl --user disable plugin_loader 2> /dev/null
rm -f /home/deck/.config/systemd/user/plugin_loader.service
systemctl stop plugin_loader 2> /dev/null
systemctl disable plugin_loader 2> /dev/null
rm -f /etc/systemd/system/plugin_loader.service
cat > /etc/systemd/system/plugin_loader.service <<- EOM
[Unit]
Description=SteamDeck Plugin Loader
[Service]
Type=simple
User=root
Restart=always
ExecStart=/home/deck/homebrew/services/PluginLoader
WorkingDirectory=/home/deck/homebrew/services
Environment=PLUGIN_PATH=/home/deck/homebrew/plugins
[Install]
WantedBy=multi-user.target
EOM
systemctl daemon-reload
systemctl start plugin_loader
systemctl enable plugin_loader
+67
View File
@@ -0,0 +1,67 @@
#!/bin/sh
[ "$UID" -eq 0 ] || exec sudo "$0" "$@"
echo "Installing Steam Deck Plugin Loader pre-release..."
USER_DIR="$(getent passwd $SUDO_USER | cut -d: -f6)"
HOMEBREW_FOLDER="${USER_DIR}/homebrew"
# Create folder structure
rm -rf "${HOMEBREW_FOLDER}/services"
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/services"
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/plugins"
# Download latest release and install it
RELEASE=$(curl -s 'https://api.github.com/repos/SteamDeckHomebrew/decky-loader/releases' | jq -r "first(.[] | select(.prerelease == "true"))")
read VERSION DOWNLOADURL < <(echo $(jq -r '.tag_name, .assets[].browser_download_url' <<< ${RELEASE}))
printf "Installing version %s...\n" "${VERSION}"
curl -L $DOWNLOADURL --output ${HOMEBREW_FOLDER}/services/PluginLoader
chmod +x ${HOMEBREW_FOLDER}/services/PluginLoader
echo $VERSION > ${HOMEBREW_FOLDER}/services/.loader.version
systemctl --user stop plugin_loader 2> /dev/null
systemctl --user disable plugin_loader 2> /dev/null
systemctl stop plugin_loader 2> /dev/null
systemctl disable plugin_loader 2> /dev/null
curl -L https://raw.githubusercontent.com/SteamDeckHomebrew/decky-loader/main/dist/plugin_loader-prerelease.service --output ${HOMEBREW_FOLDER}/services/plugin_loader-prerelease.service
cat > "${HOMEBREW_FOLDER}/services/plugin_loader-backup.service" <<- EOM
[Unit]
Description=SteamDeck Plugin Loader
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
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
EOM
if [[ -f "${HOMEBREW_FOLDER}/services/plugin_loader-prerelease.service" ]]; then
printf "Grabbed latest prerelease service.\n"
sed -i -e "s|\${HOMEBREW_FOLDER}|${HOMEBREW_FOLDER}|" "${HOMEBREW_FOLDER}/services/plugin_loader-prerelease.service"
cp -f "${HOMEBREW_FOLDER}/services/plugin_loader-prerelease.service" "/etc/systemd/system/plugin_loader.service"
else
printf "Could not curl latest prerelease systemd service, using built-in service as a backup!\n"
rm -f "/etc/systemd/system/plugin_loader.service"
cp "${HOMEBREW_FOLDER}/services/plugin_loader-backup.service" "/etc/systemd/system/plugin_loader.service"
fi
mkdir -p ${HOMEBREW_FOLDER}/services/.systemd
cp ${HOMEBREW_FOLDER}/services/plugin_loader-prerelease.service ${HOMEBREW_FOLDER}/services/.systemd/plugin_loader-prerelease.service
cp ${HOMEBREW_FOLDER}/services/plugin_loader-backup.service ${HOMEBREW_FOLDER}/services/.systemd/plugin_loader-backup.service
rm ${HOMEBREW_FOLDER}/services/plugin_loader-backup.service ${HOMEBREW_FOLDER}/services/plugin_loader-prerelease.service
systemctl daemon-reload
systemctl start plugin_loader
systemctl enable plugin_loader
+38 -10
View File
@@ -4,36 +4,64 @@
echo "Installing Steam Deck Plugin Loader release..."
HOMEBREW_FOLDER=/home/deck/homebrew
USER_DIR="$(getent passwd $SUDO_USER | cut -d: -f6)"
HOMEBREW_FOLDER="${USER_DIR}/homebrew"
# Create folder structure
rm -rf ${HOMEBREW_FOLDER}/services
sudo -u deck mkdir -p ${HOMEBREW_FOLDER}/services
sudo -u deck mkdir -p ${HOMEBREW_FOLDER}/plugins
rm -rf "${HOMEBREW_FOLDER}/services"
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/services"
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/plugins"
# Download latest release and install it
curl -L https://github.com/SteamDeckHomebrew/PluginLoader/releases/latest/download/PluginLoader --output ${HOMEBREW_FOLDER}/services/PluginLoader
RELEASE=$(curl -s 'https://api.github.com/repos/SteamDeckHomebrew/decky-loader/releases' | jq -r "first(.[] | select(.prerelease == "false"))")
read VERSION DOWNLOADURL < <(echo $(jq -r '.tag_name, .assets[].browser_download_url' <<< ${RELEASE}))
printf "Installing version %s...\n" "${VERSION}"
curl -L $DOWNLOADURL --output ${HOMEBREW_FOLDER}/services/PluginLoader
chmod +x ${HOMEBREW_FOLDER}/services/PluginLoader
echo $VERSION > ${HOMEBREW_FOLDER}/services/.loader.version
systemctl --user stop plugin_loader 2> /dev/null
systemctl --user disable plugin_loader 2> /dev/null
systemctl stop plugin_loader 2> /dev/null
systemctl disable plugin_loader 2> /dev/null
rm -f /etc/systemd/system/plugin_loader.service
cat > /etc/systemd/system/plugin_loader.service <<- EOM
curl -L https://raw.githubusercontent.com/SteamDeckHomebrew/decky-loader/main/dist/plugin_loader-release.service --output ${HOMEBREW_FOLDER}/services/plugin_loader-release.service
cat > "${HOMEBREW_FOLDER}/services/plugin_loader-backup.service" <<- EOM
[Unit]
Description=SteamDeck Plugin Loader
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=root
Restart=always
ExecStart=/home/deck/homebrew/services/PluginLoader
WorkingDirectory=/home/deck/homebrew/services
Environment=PLUGIN_PATH=/home/deck/homebrew/plugins
ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
WorkingDirectory=${HOMEBREW_FOLDER}/services
KillSignal=SIGKILL
Environment=PLUGIN_PATH=${HOMEBREW_FOLDER}/plugins
Environment=LOG_LEVEL=INFO
[Install]
WantedBy=multi-user.target
EOM
if [[ -f "${HOMEBREW_FOLDER}/services/plugin_loader-release.service" ]]; then
printf "Grabbed latest release service.\n"
sed -i -e "s|\${HOMEBREW_FOLDER}|${HOMEBREW_FOLDER}|" "${HOMEBREW_FOLDER}/services/plugin_loader-release.service"
cp -f "${HOMEBREW_FOLDER}/services/plugin_loader-release.service" "/etc/systemd/system/plugin_loader.service"
else
printf "Could not curl latest release systemd service, using built-in service as a backup!\n"
rm -f "/etc/systemd/system/plugin_loader.service"
cp "${HOMEBREW_FOLDER}/services/plugin_loader-backup.service" "/etc/systemd/system/plugin_loader.service"
fi
mkdir -p ${HOMEBREW_FOLDER}/services/.systemd
cp ${HOMEBREW_FOLDER}/services/plugin_loader-release.service ${HOMEBREW_FOLDER}/services/.systemd/plugin_loader-release.service
cp ${HOMEBREW_FOLDER}/services/plugin_loader-backup.service ${HOMEBREW_FOLDER}/services/.systemd/plugin_loader-backup.service
rm ${HOMEBREW_FOLDER}/services/plugin_loader-backup.service ${HOMEBREW_FOLDER}/services/plugin_loader-release.service
systemctl daemon-reload
systemctl start plugin_loader
systemctl enable plugin_loader
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=SteamDeck Plugin Loader
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
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
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=SteamDeck Plugin Loader
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
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
+8 -5
View File
@@ -1,17 +1,20 @@
#!/bin/sh
[ "$UID" -eq 0 ] || exec sudo "$0" "$@"
echo "Uninstalling Steam Deck Plugin Loader..."
HOMEBREW_FOLDER=/home/deck/homebrew
USER_DIR="$(getent passwd $SUDO_USER | cut -d: -f6)"
HOMEBREW_FOLDER="${USER_DIR}/homebrew"
# Disable and remove services
sudo systemctl disable --now plugin_loader.service > /dev/null
sudo rm -f /home/deck/.config/systemd/user/plugin_loader.service
sudo rm -f /etc/systemd/system/plugin_loader.service
sudo rm -f "${USER_DIR}/.config/systemd/user/plugin_loader.service"
sudo rm -f "/etc/systemd/system/plugin_loader.service"
# Remove temporary folder if it exists from the install process
rm -rf /tmp/plugin_loader
rm -rf "/tmp/plugin_loader"
# Cleanup services folder
sudo rm ${HOMEBREW_FOLDER}/services/PluginLoader
sudo rm "${HOMEBREW_FOLDER}/services/PluginLoader"
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="#000" d="M495.9 166.6c3.2 8.7 .5 18.4-6.4 24.6l-43.3 39.4c1.1 8.3 1.7 16.8 1.7 25.4s-.6 17.1-1.7 25.4l43.3 39.4c6.9 6.2 9.6 15.9 6.4 24.6c-4.4 11.9-9.7 23.3-15.8 34.3l-4.7 8.1c-6.6 11-14 21.4-22.1 31.2c-5.9 7.2-15.7 9.6-24.5 6.8l-55.7-17.7c-13.4 10.3-28.2 18.9-44 25.4l-12.5 57.1c-2 9.1-9 16.3-18.2 17.8c-13.8 2.3-28 3.5-42.5 3.5s-28.7-1.2-42.5-3.5c-9.2-1.5-16.2-8.7-18.2-17.8l-12.5-57.1c-15.8-6.5-30.6-15.1-44-25.4L83.1 425.9c-8.8 2.8-18.6 .3-24.5-6.8c-8.1-9.8-15.5-20.2-22.1-31.2l-4.7-8.1c-6.1-11-11.4-22.4-15.8-34.3c-3.2-8.7-.5-18.4 6.4-24.6l43.3-39.4C64.6 273.1 64 264.6 64 256s.6-17.1 1.7-25.4L22.4 191.2c-6.9-6.2-9.6-15.9-6.4-24.6c4.4-11.9 9.7-23.3 15.8-34.3l4.7-8.1c6.6-11 14-21.4 22.1-31.2c5.9-7.2 15.7-9.6 24.5-6.8l55.7 17.7c13.4-10.3 28.2-18.9 44-25.4l12.5-57.1c2-9.1 9-16.3 18.2-17.8C227.3 1.2 241.5 0 256 0s28.7 1.2 42.5 3.5c9.2 1.5 16.2 8.7 18.2 17.8l12.5 57.1c15.8 6.5 30.6 15.1 44 25.4l55.7-17.7c8.8-2.8 18.6-.3 24.5 6.8c8.1 9.8 15.5 20.2 22.1 31.2l4.7 8.1c6.1 11 11.4 22.4 15.8 34.3zM256 336c44.2 0 80-35.8 80-80s-35.8-80-80-80s-80 35.8-80 80s35.8 80 80 80z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--! Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="#000" d="M96 0C78.3 0 64 14.3 64 32v96h64V32c0-17.7-14.3-32-32-32zM288 0c-17.7 0-32 14.3-32 32v96h64V32c0-17.7-14.3-32-32-32zM32 160c-17.7 0-32 14.3-32 32s14.3 32 32 32v32c0 77.4 55 142 128 156.8V480c0 17.7 14.3 32 32 32s32-14.3 32-32V412.8C297 398 352 333.4 352 256V224c17.7 0 32-14.3 32-32s-14.3-32-32-32H32z"/></svg>

After

Width:  |  Height:  |  Size: 561 B

+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="32" height="14" viewBox="0.395 9 31.21 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.39502 16C0.39502 12.134 3.57877 9 7.50613 9H24.4938C28.4211 9 31.6049 12.134 31.6049 16C31.6049 19.866 28.4211 23 24.4938 23H7.50613C3.57877 23 0.39502 19.866 0.39502 16Z" fill="#000"/>
<ellipse cx="8.88886" cy="16" rx="1.77778" ry="1.75" fill="#fff"/>
<ellipse cx="15.9999" cy="16" rx="1.77778" ry="1.75" fill="#fff"/>
<ellipse cx="23.111" cy="16" rx="1.77778" ry="1.75" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 554 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 36" fill="none" class="footericons_SizeMedium_3-w0R footericons_Light_2e0Fq"><path class="footericons_Background_I3P4e" fill="#000" d="M0 18C0 8.05888 8.05888 0 18 0H82C91.9411 0 100 8.05888 100 18C100 27.9411 91.9411 36 82 36H18C8.05888 36 0 27.9411 0 18Z"></path><path class="footericons_Foreground_39K5g" fill="#fff" d="M21.8011 11.5C22.6531 11.5 23.4391 11.62 24.1591 11.86C24.8791 12.1 25.4851 12.394 25.9771 12.742L24.8611 14.722C24.4171 14.41 23.9191 14.158 23.3671 13.966C22.8271 13.774 22.3111 13.678 21.8191 13.678C21.2191 13.678 20.7511 13.804 20.4151 14.056C20.0791 14.296 19.9111 14.632 19.9111 15.064C19.9111 15.496 20.1091 15.838 20.5051 16.09C20.9011 16.33 21.5071 16.594 22.3231 16.882C23.1631 17.182 23.8351 17.458 24.3391 17.71C24.8431 17.962 25.2811 18.334 25.6531 18.826C26.0371 19.306 26.2291 19.924 26.2291 20.68C26.2291 21.484 26.0191 22.18 25.5991 22.768C25.1911 23.356 24.6151 23.812 23.8711 24.136C23.1271 24.448 22.2751 24.604 21.3151 24.604C20.5351 24.604 19.7371 24.502 18.9211 24.298C18.1171 24.082 17.4091 23.794 16.7971 23.434L17.6251 21.238C18.2011 21.55 18.8071 21.802 19.4431 21.994C20.0911 22.174 20.7271 22.264 21.3511 22.264C22.0351 22.264 22.5451 22.132 22.8811 21.868C23.2291 21.604 23.4031 21.256 23.4031 20.824C23.4031 20.392 23.2171 20.056 22.8451 19.816C22.4731 19.576 21.9031 19.33 21.1351 19.078C20.2711 18.802 19.5751 18.538 19.0471 18.286C18.5191 18.022 18.0631 17.644 17.6791 17.152C17.3071 16.648 17.1211 15.994 17.1211 15.19C17.1211 14.446 17.3131 13.798 17.6971 13.246C18.0931 12.682 18.6451 12.25 19.3531 11.95C20.0611 11.65 20.8771 11.5 21.8011 11.5Z"></path><path class="footericons_Foreground_39K5g" fill="#fff" d="M35.2486 24.388H32.6026V14.056H28.7866V11.788H39.0646V14.056H35.2486V24.388Z"></path><path class="footericons_Foreground_39K5g" fill="#fff" d="M42.3148 11.788H50.8108V14.038H44.9608V16.882H50.0008V19.15H44.9608V22.102H50.8108V24.388H42.3148V11.788Z"></path><path class="footericons_Foreground_39K5g" fill="#fff" d="M65.8582 24.388H62.9962L62.1322 21.94H57.2002L56.3722 24.388H53.6182L58.3342 11.788H60.9982L65.8582 24.388ZM59.6482 14.794L57.9202 19.834H61.4122L59.6482 14.794Z"></path><path class="footericons_Foreground_39K5g" fill="#fff" d="M75.8489 20.734L79.7729 11.788H82.4549V24.388H79.9169V16.378L76.5329 24.028H74.9309L71.4749 16.468V24.388H69.0629V11.788H71.6009L75.8489 20.734Z"></path></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--! Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="#000" d="M547.6 103.8L490.3 13.1C485.2 5 476.1 0 466.4 0H109.6C99.9 0 90.8 5 85.7 13.1L28.3 103.8c-29.6 46.8-3.4 111.9 51.9 119.4c4 .5 8.1 .8 12.1 .8c26.1 0 49.3-11.4 65.2-29c15.9 17.6 39.1 29 65.2 29c26.1 0 49.3-11.4 65.2-29c15.9 17.6 39.1 29 65.2 29c26.2 0 49.3-11.4 65.2-29c16 17.6 39.1 29 65.2 29c4.1 0 8.1-.3 12.1-.8c55.5-7.4 81.8-72.5 52.1-119.4zM499.7 254.9l-.1 0c-5.3 .7-10.7 1.1-16.2 1.1c-12.4 0-24.3-1.9-35.4-5.3V384H128V250.6c-11.2 3.5-23.2 5.4-35.6 5.4c-5.5 0-11-.4-16.3-1.1l-.1 0c-4.1-.6-8.1-1.3-12-2.3V384v64c0 35.3 28.7 64 64 64H448c35.3 0 64-28.7 64-64V384 252.6c-4 1-8 1.8-12.3 2.3z"/></svg>

After

Width:  |  Height:  |  Size: 850 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 30C23.732 30 30 23.732 30 16C30 8.26801 23.732 2 16 2C8.26801 2 2 8.26801 2 16C2 23.732 8.26801 30 16 30ZM22.0775 9H18.5059L15.8368 13.3393L13.1677 9H9.69202L14.2814 15.5186L9.5 22.5H12.8796L15.8944 17.5821L19.0436 22.5H22.5L17.3538 15.4221L22.0775 9Z" fill="#000"/>
</svg>

After

Width:  |  Height:  |  Size: 423 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="#fff" d="M495.9 166.6c3.2 8.7 .5 18.4-6.4 24.6l-43.3 39.4c1.1 8.3 1.7 16.8 1.7 25.4s-.6 17.1-1.7 25.4l43.3 39.4c6.9 6.2 9.6 15.9 6.4 24.6c-4.4 11.9-9.7 23.3-15.8 34.3l-4.7 8.1c-6.6 11-14 21.4-22.1 31.2c-5.9 7.2-15.7 9.6-24.5 6.8l-55.7-17.7c-13.4 10.3-28.2 18.9-44 25.4l-12.5 57.1c-2 9.1-9 16.3-18.2 17.8c-13.8 2.3-28 3.5-42.5 3.5s-28.7-1.2-42.5-3.5c-9.2-1.5-16.2-8.7-18.2-17.8l-12.5-57.1c-15.8-6.5-30.6-15.1-44-25.4L83.1 425.9c-8.8 2.8-18.6 .3-24.5-6.8c-8.1-9.8-15.5-20.2-22.1-31.2l-4.7-8.1c-6.1-11-11.4-22.4-15.8-34.3c-3.2-8.7-.5-18.4 6.4-24.6l43.3-39.4C64.6 273.1 64 264.6 64 256s.6-17.1 1.7-25.4L22.4 191.2c-6.9-6.2-9.6-15.9-6.4-24.6c4.4-11.9 9.7-23.3 15.8-34.3l4.7-8.1c6.6-11 14-21.4 22.1-31.2c5.9-7.2 15.7-9.6 24.5-6.8l55.7 17.7c13.4-10.3 28.2-18.9 44-25.4l12.5-57.1c2-9.1 9-16.3 18.2-17.8C227.3 1.2 241.5 0 256 0s28.7 1.2 42.5 3.5c9.2 1.5 16.2 8.7 18.2 17.8l12.5 57.1c15.8 6.5 30.6 15.1 44 25.4l55.7-17.7c8.8-2.8 18.6-.3 24.5 6.8c8.1 9.8 15.5 20.2 22.1 31.2l4.7 8.1c6.1 11 11.4 22.4 15.8 34.3zM256 336c44.2 0 80-35.8 80-80s-35.8-80-80-80s-80 35.8-80 80s35.8 80 80 80z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--! Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="#fff" d="M96 0C78.3 0 64 14.3 64 32v96h64V32c0-17.7-14.3-32-32-32zM288 0c-17.7 0-32 14.3-32 32v96h64V32c0-17.7-14.3-32-32-32zM32 160c-17.7 0-32 14.3-32 32s14.3 32 32 32v32c0 77.4 55 142 128 156.8V480c0 17.7 14.3 32 32 32s32-14.3 32-32V412.8C297 398 352 333.4 352 256V224c17.7 0 32-14.3 32-32s-14.3-32-32-32H32z"/></svg>

After

Width:  |  Height:  |  Size: 561 B

+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="32" height="14" viewBox="0.395 9 31.21 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.39502 16C0.39502 12.134 3.57877 9 7.50613 9H24.4938C28.4211 9 31.6049 12.134 31.6049 16C31.6049 19.866 28.4211 23 24.4938 23H7.50613C3.57877 23 0.39502 19.866 0.39502 16Z" fill="white"/>
<ellipse cx="8.88886" cy="16" rx="1.77778" ry="1.75" style="fill: rgb(0, 0, 0);"/>
<ellipse cx="15.9999" cy="16" rx="1.77778" ry="1.75" style="fill: rgb(0, 0, 0);"/>
<ellipse cx="23.111" cy="16" rx="1.77778" ry="1.75" style="fill: rgb(0, 0, 0);"/>
</svg>

After

Width:  |  Height:  |  Size: 603 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 36" fill="none" class="footericons_SizeMedium_3-w0R footericons_Light_2e0Fq"><path class="footericons_Background_I3P4e" fill="#fff" d="M0 18C0 8.05888 8.05888 0 18 0H82C91.9411 0 100 8.05888 100 18C100 27.9411 91.9411 36 82 36H18C8.05888 36 0 27.9411 0 18Z"></path><path class="footericons_Foreground_39K5g" fill="#000" d="M21.8011 11.5C22.6531 11.5 23.4391 11.62 24.1591 11.86C24.8791 12.1 25.4851 12.394 25.9771 12.742L24.8611 14.722C24.4171 14.41 23.9191 14.158 23.3671 13.966C22.8271 13.774 22.3111 13.678 21.8191 13.678C21.2191 13.678 20.7511 13.804 20.4151 14.056C20.0791 14.296 19.9111 14.632 19.9111 15.064C19.9111 15.496 20.1091 15.838 20.5051 16.09C20.9011 16.33 21.5071 16.594 22.3231 16.882C23.1631 17.182 23.8351 17.458 24.3391 17.71C24.8431 17.962 25.2811 18.334 25.6531 18.826C26.0371 19.306 26.2291 19.924 26.2291 20.68C26.2291 21.484 26.0191 22.18 25.5991 22.768C25.1911 23.356 24.6151 23.812 23.8711 24.136C23.1271 24.448 22.2751 24.604 21.3151 24.604C20.5351 24.604 19.7371 24.502 18.9211 24.298C18.1171 24.082 17.4091 23.794 16.7971 23.434L17.6251 21.238C18.2011 21.55 18.8071 21.802 19.4431 21.994C20.0911 22.174 20.7271 22.264 21.3511 22.264C22.0351 22.264 22.5451 22.132 22.8811 21.868C23.2291 21.604 23.4031 21.256 23.4031 20.824C23.4031 20.392 23.2171 20.056 22.8451 19.816C22.4731 19.576 21.9031 19.33 21.1351 19.078C20.2711 18.802 19.5751 18.538 19.0471 18.286C18.5191 18.022 18.0631 17.644 17.6791 17.152C17.3071 16.648 17.1211 15.994 17.1211 15.19C17.1211 14.446 17.3131 13.798 17.6971 13.246C18.0931 12.682 18.6451 12.25 19.3531 11.95C20.0611 11.65 20.8771 11.5 21.8011 11.5Z"></path><path class="footericons_Foreground_39K5g" fill="#000" d="M35.2486 24.388H32.6026V14.056H28.7866V11.788H39.0646V14.056H35.2486V24.388Z"></path><path class="footericons_Foreground_39K5g" fill="#000" d="M42.3148 11.788H50.8108V14.038H44.9608V16.882H50.0008V19.15H44.9608V22.102H50.8108V24.388H42.3148V11.788Z"></path><path class="footericons_Foreground_39K5g" fill="#000" d="M65.8582 24.388H62.9962L62.1322 21.94H57.2002L56.3722 24.388H53.6182L58.3342 11.788H60.9982L65.8582 24.388ZM59.6482 14.794L57.9202 19.834H61.4122L59.6482 14.794Z"></path><path class="footericons_Foreground_39K5g" fill="#000" d="M75.8489 20.734L79.7729 11.788H82.4549V24.388H79.9169V16.378L76.5329 24.028H74.9309L71.4749 16.468V24.388H69.0629V11.788H71.6009L75.8489 20.734Z"></path></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--! Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="#fff" d="M547.6 103.8L490.3 13.1C485.2 5 476.1 0 466.4 0H109.6C99.9 0 90.8 5 85.7 13.1L28.3 103.8c-29.6 46.8-3.4 111.9 51.9 119.4c4 .5 8.1 .8 12.1 .8c26.1 0 49.3-11.4 65.2-29c15.9 17.6 39.1 29 65.2 29c26.1 0 49.3-11.4 65.2-29c15.9 17.6 39.1 29 65.2 29c26.2 0 49.3-11.4 65.2-29c16 17.6 39.1 29 65.2 29c4.1 0 8.1-.3 12.1-.8c55.5-7.4 81.8-72.5 52.1-119.4zM499.7 254.9l-.1 0c-5.3 .7-10.7 1.1-16.2 1.1c-12.4 0-24.3-1.9-35.4-5.3V384H128V250.6c-11.2 3.5-23.2 5.4-35.6 5.4c-5.5 0-11-.4-16.3-1.1l-.1 0c-4.1-.6-8.1-1.3-12-2.3V384v64c0 35.3 28.7 64 64 64H448c35.3 0 64-28.7 64-64V384 252.6c-4 1-8 1.8-12.3 2.3z"/></svg>

After

Width:  |  Height:  |  Size: 850 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 30C23.732 30 30 23.732 30 16C30 8.26801 23.732 2 16 2C8.26801 2 2 8.26801 2 16C2 23.732 8.26801 30 16 30ZM22.0775 9H18.5059L15.8368 13.3393L13.1677 9H9.69202L14.2814 15.5186L9.5 22.5H12.8796L15.8944 17.5821L19.0436 22.5H22.5L17.3538 15.4221L22.0775 9Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 424 B

-3881
View File
File diff suppressed because it is too large Load Diff
+16 -8
View File
@@ -1,6 +1,6 @@
{
"name": "decky_frontend",
"version": "0.0.1",
"version": "2.1.1",
"private": true,
"license": "GPLV2",
"scripts": {
@@ -13,21 +13,26 @@
"devDependencies": {
"@rollup/plugin-commonjs": "^21.1.0",
"@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-node-resolve": "^13.2.1",
"@rollup/plugin-node-resolve": "^13.3.0",
"@rollup/plugin-replace": "^4.0.0",
"@rollup/plugin-typescript": "^8.3.2",
"@rollup/plugin-typescript": "^8.3.3",
"@types/react": "16.14.0",
"@types/react-file-icon": "^1.0.1",
"@types/react-router": "5.1.18",
"@types/webpack": "^5.28.0",
"husky": "^8.0.1",
"import-sort-style-module": "^6.0.0",
"prettier": "^2.6.2",
"inquirer": "^8.2.4",
"prettier": "^2.7.1",
"prettier-plugin-import-sort": "^0.0.7",
"react": "16.14.0",
"react-dom": "16.14.0",
"rollup": "^2.70.2",
"rollup": "^2.76.0",
"rollup-plugin-delete": "^2.0.0",
"rollup-plugin-external-globals": "^0.6.1",
"rollup-plugin-polyfill-node": "^0.10.2",
"tslib": "^2.4.0",
"typescript": "^4.7.2"
"typescript": "^4.7.4"
},
"importSort": {
".js, .jsx, .ts, .tsx": {
@@ -36,7 +41,10 @@
}
},
"dependencies": {
"decky-frontend-lib": "^0.0.6",
"react-icons": "^4.3.1"
"decky-frontend-lib": "^3.18.4",
"react-file-icon": "^1.2.0",
"react-icons": "^4.4.0",
"react-markdown": "^8.0.3",
"remark-gfm": "^3.0.1"
}
}
+2694
View File
File diff suppressed because it is too large Load Diff
+22 -6
View File
@@ -4,12 +4,25 @@ import { nodeResolve } from '@rollup/plugin-node-resolve';
import replace from '@rollup/plugin-replace';
import typescript from '@rollup/plugin-typescript';
import { defineConfig } from 'rollup';
import del from 'rollup-plugin-delete';
import externalGlobals from 'rollup-plugin-external-globals';
const hiddenWarnings = ['THIS_IS_UNDEFINED', 'EVAL'];
export default defineConfig({
input: 'src/index.tsx',
plugins: [
del({ targets: '../backend/static/*', force: true }),
commonjs(),
nodeResolve(),
externalGlobals({
react: 'SP_REACT',
'react-dom': 'SP_REACTDOM',
// hack to shut up react-markdown
process: '{cwd: () => {}}',
path: '{dirname: () => {}, join: () => {}, basename: () => {}, extname: () => {}}',
url: '{fileURLToPath: (f) => f}',
}),
typescript(),
json(),
replace({
@@ -17,13 +30,16 @@ export default defineConfig({
'process.env.NODE_ENV': JSON.stringify('production'),
}),
],
external: ["react", "react-dom"],
preserveEntrySignatures: false,
output: {
file: '../backend/static/plugin-loader.iife.js',
globals: {
react: 'SP_REACT',
'react-dom': 'SP_REACTDOM',
dir: '../backend/static',
format: 'esm',
chunkFileNames: (chunkInfo) => {
return 'chunk-[hash].js';
},
format: 'iife',
},
onwarn: function (message, handleWarning) {
if (hiddenWarnings.some((warning) => message.code === warning)) return;
handleWarning(message);
},
});
@@ -0,0 +1,74 @@
import { FC, createContext, useContext, useEffect, useState } from 'react';
interface PublicDeckyGlobalComponentsState {
components: Map<string, FC>;
}
export class DeckyGlobalComponentsState {
// TODO a set would be better
private _components = new Map<string, FC>();
public eventBus = new EventTarget();
publicState(): PublicDeckyGlobalComponentsState {
return { components: this._components };
}
addComponent(name: string, component: FC) {
this._components.set(name, component);
this.notifyUpdate();
}
removeComponent(name: string) {
this._components.delete(name);
this.notifyUpdate();
}
private notifyUpdate() {
this.eventBus.dispatchEvent(new Event('update'));
}
}
interface DeckyGlobalComponentsContext extends PublicDeckyGlobalComponentsState {
addComponent(name: string, component: FC): void;
removeComponent(name: string): void;
}
const DeckyGlobalComponentsContext = createContext<DeckyGlobalComponentsContext>(null as any);
export const useDeckyGlobalComponentsState = () => useContext(DeckyGlobalComponentsContext);
interface Props {
deckyGlobalComponentsState: DeckyGlobalComponentsState;
}
export const DeckyGlobalComponentsStateContextProvider: FC<Props> = ({
children,
deckyGlobalComponentsState: deckyGlobalComponentsState,
}) => {
const [publicDeckyGlobalComponentsState, setPublicDeckyGlobalComponentsState] =
useState<PublicDeckyGlobalComponentsState>({
...deckyGlobalComponentsState.publicState(),
});
useEffect(() => {
function onUpdate() {
setPublicDeckyGlobalComponentsState({ ...deckyGlobalComponentsState.publicState() });
}
deckyGlobalComponentsState.eventBus.addEventListener('update', onUpdate);
return () => deckyGlobalComponentsState.eventBus.removeEventListener('update', onUpdate);
}, []);
const addComponent = deckyGlobalComponentsState.addComponent.bind(deckyGlobalComponentsState);
const removeComponent = deckyGlobalComponentsState.removeComponent.bind(deckyGlobalComponentsState);
return (
<DeckyGlobalComponentsContext.Provider
value={{ ...publicDeckyGlobalComponentsState, addComponent, removeComponent }}
>
{children}
</DeckyGlobalComponentsContext.Provider>
);
};
+147
View File
@@ -0,0 +1,147 @@
import { CustomMainMenuItem, ItemPatch, OverlayPatch } from 'decky-frontend-lib';
import { FC, ReactNode, createContext, useContext, useEffect, useState } from 'react';
interface PublicDeckyMenuState {
items: Set<CustomMainMenuItem>;
itemPatches: Map<string, Set<ItemPatch>>;
overlayPatches: Set<OverlayPatch>;
overlayComponents: Set<ReactNode>;
}
export class DeckyMenuState {
private _items = new Set<CustomMainMenuItem>();
private _itemPatches = new Map<string, Set<ItemPatch>>();
private _overlayPatches = new Set<OverlayPatch>();
private _overlayComponents = new Set<ReactNode>();
public eventBus = new EventTarget();
publicState(): PublicDeckyMenuState {
return {
items: this._items,
itemPatches: this._itemPatches,
overlayPatches: this._overlayPatches,
overlayComponents: this._overlayComponents,
};
}
addItem(item: CustomMainMenuItem) {
this._items.add(item);
this.notifyUpdate();
return item;
}
addPatch(path: string, patch: ItemPatch) {
let patchList = this._itemPatches.get(path);
if (!patchList) {
patchList = new Set();
this._itemPatches.set(path, patchList);
}
patchList.add(patch);
this.notifyUpdate();
return patch;
}
addOverlayPatch(patch: OverlayPatch) {
this._overlayPatches.add(patch);
this.notifyUpdate();
return patch;
}
addOverlayComponent(component: ReactNode) {
this._overlayComponents.add(component);
this.notifyUpdate();
return component;
}
removePatch(path: string, patch: ItemPatch) {
const patchList = this._itemPatches.get(path);
patchList?.delete(patch);
if (patchList?.size == 0) {
this._itemPatches.delete(path);
}
this.notifyUpdate();
}
removeItem(item: CustomMainMenuItem) {
this._items.delete(item);
this.notifyUpdate();
return item;
}
removeOverlayPatch(patch: OverlayPatch) {
this._overlayPatches.delete(patch);
this.notifyUpdate();
}
removeOverlayComponent(component: ReactNode) {
this._overlayComponents.delete(component);
this.notifyUpdate();
}
private notifyUpdate() {
this.eventBus.dispatchEvent(new Event('update'));
}
}
interface DeckyMenuStateContext extends PublicDeckyMenuState {
addItem: DeckyMenuState['addItem'];
addPatch: DeckyMenuState['addPatch'];
addOverlayPatch: DeckyMenuState['addOverlayPatch'];
addOverlayComponent: DeckyMenuState['addOverlayComponent'];
removePatch: DeckyMenuState['removePatch'];
removeOverlayPatch: DeckyMenuState['removeOverlayPatch'];
removeOverlayComponent: DeckyMenuState['removeOverlayComponent'];
removeItem: DeckyMenuState['removeItem'];
}
const DeckyMenuStateContext = createContext<DeckyMenuStateContext>(null as any);
export const useDeckyMenuState = () => useContext(DeckyMenuStateContext);
interface Props {
deckyMenuState: DeckyMenuState;
}
export const DeckyMenuStateContextProvider: FC<Props> = ({ children, deckyMenuState }) => {
const [publicDeckyMenuState, setPublicDeckyMenuState] = useState<PublicDeckyMenuState>({
...deckyMenuState.publicState(),
});
useEffect(() => {
function onUpdate() {
setPublicDeckyMenuState({ ...deckyMenuState.publicState() });
}
deckyMenuState.eventBus.addEventListener('update', onUpdate);
return () => deckyMenuState.eventBus.removeEventListener('update', onUpdate);
}, []);
const addItem = deckyMenuState.addItem.bind(deckyMenuState);
const addPatch = deckyMenuState.addPatch.bind(deckyMenuState);
const addOverlayPatch = deckyMenuState.addOverlayPatch.bind(deckyMenuState);
const addOverlayComponent = deckyMenuState.addOverlayComponent.bind(deckyMenuState);
const removePatch = deckyMenuState.removePatch.bind(deckyMenuState);
const removeOverlayPatch = deckyMenuState.removeOverlayPatch.bind(deckyMenuState);
const removeOverlayComponent = deckyMenuState.removeOverlayComponent.bind(deckyMenuState);
const removeItem = deckyMenuState.removeItem.bind(deckyMenuState);
return (
<DeckyMenuStateContext.Provider
value={{
...publicDeckyMenuState,
addItem,
addPatch,
addOverlayPatch,
addOverlayComponent,
removePatch,
removeOverlayPatch,
removeOverlayComponent,
removeItem,
}}
>
{children}
</DeckyMenuStateContext.Provider>
);
};
+34 -5
View File
@@ -6,17 +6,21 @@ export interface RouterEntry {
component: ComponentType;
}
export type RoutePatch = (route: RouteProps) => RouteProps;
interface PublicDeckyRouterState {
routes: Map<string, RouterEntry>;
routePatches: Map<string, Set<RoutePatch>>;
}
export class DeckyRouterState {
private _routes = new Map<string, RouterEntry>();
private _routePatches = new Map<string, Set<RoutePatch>>();
public eventBus = new EventTarget();
publicState(): PublicDeckyRouterState {
return { routes: this._routes };
return { routes: this._routes, routePatches: this._routePatches };
}
addRoute(path: string, component: RouterEntry['component'], props: RouterEntry['props'] = {}) {
@@ -24,6 +28,26 @@ export class DeckyRouterState {
this.notifyUpdate();
}
addPatch(path: string, patch: RoutePatch) {
let patchList = this._routePatches.get(path);
if (!patchList) {
patchList = new Set();
this._routePatches.set(path, patchList);
}
patchList.add(patch);
this.notifyUpdate();
return patch;
}
removePatch(path: string, patch: RoutePatch) {
const patchList = this._routePatches.get(path);
patchList?.delete(patch);
if (patchList?.size == 0) {
this._routePatches.delete(path);
}
this.notifyUpdate();
}
removeRoute(path: string) {
this._routes.delete(path);
this.notifyUpdate();
@@ -36,6 +60,8 @@ export class DeckyRouterState {
interface DeckyRouterStateContext extends PublicDeckyRouterState {
addRoute(path: string, component: RouterEntry['component'], props: RouterEntry['props']): void;
addPatch(path: string, patch: RoutePatch): RoutePatch;
removePatch(path: string, patch: RoutePatch): void;
removeRoute(path: string): void;
}
@@ -62,12 +88,15 @@ export const DeckyRouterStateContextProvider: FC<Props> = ({ children, deckyRout
return () => deckyRouterState.eventBus.removeEventListener('update', onUpdate);
}, []);
const addRoute = (path: string, component: RouterEntry['component'], props: RouterEntry['props'] = {}) =>
deckyRouterState.addRoute(path, component, props);
const removeRoute = (path: string) => deckyRouterState.removeRoute(path);
const addRoute = deckyRouterState.addRoute.bind(deckyRouterState);
const addPatch = deckyRouterState.addPatch.bind(deckyRouterState);
const removePatch = deckyRouterState.removePatch.bind(deckyRouterState);
const removeRoute = deckyRouterState.removeRoute.bind(deckyRouterState);
return (
<DeckyRouterStateContext.Provider value={{ ...publicDeckyRouterState, addRoute, removeRoute }}>
<DeckyRouterStateContext.Provider
value={{ ...publicDeckyRouterState, addRoute, addPatch, removePatch, removeRoute }}
>
{children}
</DeckyRouterStateContext.Provider>
);
+45 -2
View File
@@ -1,20 +1,42 @@
import { FC, createContext, useContext, useEffect, useState } from 'react';
import { Plugin } from '../plugin';
import { PluginUpdateMapping } from '../store';
import { VerInfo } from '../updater';
interface PublicDeckyState {
plugins: Plugin[];
activePlugin: Plugin | null;
updates: PluginUpdateMapping | null;
hasLoaderUpdate?: boolean;
isLoaderUpdating: boolean;
versionInfo: VerInfo | null;
}
export class DeckyState {
private _plugins: Plugin[] = [];
private _activePlugin: Plugin | null = null;
private _updates: PluginUpdateMapping | null = null;
private _hasLoaderUpdate: boolean = false;
private _isLoaderUpdating: boolean = false;
private _versionInfo: VerInfo | null = null;
public eventBus = new EventTarget();
publicState(): PublicDeckyState {
return { plugins: this._plugins, activePlugin: this._activePlugin };
return {
plugins: this._plugins,
activePlugin: this._activePlugin,
updates: this._updates,
hasLoaderUpdate: this._hasLoaderUpdate,
isLoaderUpdating: this._isLoaderUpdating,
versionInfo: this._versionInfo,
};
}
setVersionInfo(versionInfo: VerInfo) {
this._versionInfo = versionInfo;
this.notifyUpdate();
}
setPlugins(plugins: Plugin[]) {
@@ -32,12 +54,29 @@ export class DeckyState {
this.notifyUpdate();
}
setUpdates(updates: PluginUpdateMapping) {
this._updates = updates;
this.notifyUpdate();
}
setHasLoaderUpdate(hasUpdate: boolean) {
this._hasLoaderUpdate = hasUpdate;
this.notifyUpdate();
}
setIsLoaderUpdating(isUpdating: boolean) {
this._isLoaderUpdating = isUpdating;
this.notifyUpdate();
}
private notifyUpdate() {
this.eventBus.dispatchEvent(new Event('update'));
}
}
interface DeckyStateContext extends PublicDeckyState {
setVersionInfo(versionInfo: VerInfo): void;
setIsLoaderUpdating(hasUpdate: boolean): void;
setActivePlugin(name: string): void;
closeActivePlugin(): void;
}
@@ -63,11 +102,15 @@ export const DeckyStateContextProvider: FC<Props> = ({ children, deckyState }) =
return () => deckyState.eventBus.removeEventListener('update', onUpdate);
}, []);
const setIsLoaderUpdating = (hasUpdate: boolean) => deckyState.setIsLoaderUpdating(hasUpdate);
const setVersionInfo = (versionInfo: VerInfo) => deckyState.setVersionInfo(versionInfo);
const setActivePlugin = (name: string) => deckyState.setActivePlugin(name);
const closeActivePlugin = () => deckyState.closeActivePlugin();
return (
<DeckyStateContext.Provider value={{ ...publicDeckyState, setActivePlugin, closeActivePlugin }}>
<DeckyStateContext.Provider
value={{ ...publicDeckyState, setIsLoaderUpdating, setVersionInfo, setActivePlugin, closeActivePlugin }}
>
{children}
</DeckyStateContext.Provider>
);
+54
View File
@@ -0,0 +1,54 @@
import { ToastData, joinClassNames } from 'decky-frontend-lib';
import { FC, useEffect, useState } from 'react';
import { ReactElement } from 'react-markdown/lib/react-markdown';
import { useDeckyToasterState } from './DeckyToasterState';
import Toast, { toastClasses } from './Toast';
interface DeckyToasterProps {}
interface RenderedToast {
component: ReactElement;
data: ToastData;
}
const DeckyToaster: FC<DeckyToasterProps> = () => {
const { toasts, removeToast } = useDeckyToasterState();
const [renderedToast, setRenderedToast] = useState<RenderedToast | null>(null);
console.log(toasts);
if (toasts.size > 0) {
const [activeToast] = toasts;
if (!renderedToast || activeToast != renderedToast.data) {
// TODO play toast sound
console.log('rendering toast', activeToast);
setRenderedToast({ component: <Toast key={Math.random()} toast={activeToast} />, data: activeToast });
}
} else {
if (renderedToast) setRenderedToast(null);
}
useEffect(() => {
// not actually node but TS is shit
let interval: NodeJS.Timer | null;
if (renderedToast) {
interval = setTimeout(() => {
interval = null;
console.log('clear toast', renderedToast.data);
removeToast(renderedToast.data);
}, (renderedToast.data.duration || 5e3) + 1000);
console.log('set int', interval);
}
return () => {
if (interval) {
console.log('clearing int', interval);
clearTimeout(interval);
}
};
}, [renderedToast]);
return (
<div className={joinClassNames('deckyToaster', toastClasses.ToastPlaceholder)}>
{renderedToast && renderedToast.component}
</div>
);
};
export default DeckyToaster;
@@ -0,0 +1,69 @@
import { ToastData } from 'decky-frontend-lib';
import { FC, createContext, useContext, useEffect, useState } from 'react';
interface PublicDeckyToasterState {
toasts: Set<ToastData>;
}
export class DeckyToasterState {
// TODO a set would be better
private _toasts: Set<ToastData> = new Set();
public eventBus = new EventTarget();
publicState(): PublicDeckyToasterState {
return { toasts: this._toasts };
}
addToast(toast: ToastData) {
this._toasts.add(toast);
this.notifyUpdate();
}
removeToast(toast: ToastData) {
this._toasts.delete(toast);
this.notifyUpdate();
}
private notifyUpdate() {
this.eventBus.dispatchEvent(new Event('update'));
}
}
interface DeckyToasterContext extends PublicDeckyToasterState {
addToast(toast: ToastData): void;
removeToast(toast: ToastData): void;
}
const DeckyToasterContext = createContext<DeckyToasterContext>(null as any);
export const useDeckyToasterState = () => useContext(DeckyToasterContext);
interface Props {
deckyToasterState: DeckyToasterState;
}
export const DeckyToasterStateContextProvider: FC<Props> = ({ children, deckyToasterState }) => {
const [publicDeckyToasterState, setPublicDeckyToasterState] = useState<PublicDeckyToasterState>({
...deckyToasterState.publicState(),
});
useEffect(() => {
function onUpdate() {
setPublicDeckyToasterState({ ...deckyToasterState.publicState() });
}
deckyToasterState.eventBus.addEventListener('update', onUpdate);
return () => deckyToasterState.eventBus.removeEventListener('update', onUpdate);
}, []);
const addToast = deckyToasterState.addToast.bind(deckyToasterState);
const removeToast = deckyToasterState.removeToast.bind(deckyToasterState);
return (
<DeckyToasterContext.Provider value={{ ...publicDeckyToasterState, addToast, removeToast }}>
{children}
</DeckyToasterContext.Provider>
);
};
+42
View File
@@ -0,0 +1,42 @@
import { Focusable, Router } from 'decky-frontend-lib';
import { FunctionComponent, useRef } from 'react';
import ReactMarkdown, { Options as ReactMarkdownOptions } from 'react-markdown';
import remarkGfm from 'remark-gfm';
interface MarkdownProps extends ReactMarkdownOptions {
onDismiss?: () => void;
}
const Markdown: FunctionComponent<MarkdownProps> = (props) => {
return (
<Focusable>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
div: (nodeProps) => <Focusable {...nodeProps.node.properties}>{nodeProps.children}</Focusable>,
a: (nodeProps) => {
const aRef = useRef<HTMLAnchorElement>(null);
return (
// TODO fix focus ring
<Focusable
onActivate={() => {}}
onOKButton={() => {
props.onDismiss?.();
Router.NavigateToExternalWeb(aRef.current!.href);
}}
style={{ display: 'inline' }}
>
<a ref={aRef} {...nodeProps.node.properties}>
{nodeProps.children}
</a>
</Focusable>
);
},
}}
{...props}
/>
</Focusable>
);
};
export default Markdown;
@@ -0,0 +1,25 @@
import { CSSProperties, FunctionComponent } from 'react';
interface NotificationBadgeProps {
show?: boolean;
style?: CSSProperties;
}
const NotificationBadge: FunctionComponent<NotificationBadgeProps> = ({ show, style }) => {
return show ? (
<div
style={{
position: 'absolute',
top: '8px',
right: '8px',
height: '10px',
width: '10px',
background: 'orange',
borderRadius: '50%',
...style,
}}
/>
) : null;
};
export default NotificationBadge;
+41 -32
View File
@@ -1,48 +1,57 @@
import { ButtonItem, DialogButton, PanelSection, PanelSectionRow, Router } from 'decky-frontend-lib';
import {
ButtonItem,
Focusable,
PanelSection,
PanelSectionRow,
joinClassNames,
scrollClasses,
staticClasses,
} from 'decky-frontend-lib';
import { VFC } from 'react';
import { FaArrowLeft, FaStore } from 'react-icons/fa';
import { useDeckyState } from './DeckyState';
import NotificationBadge from './NotificationBadge';
import { useQuickAccessVisible } from './QuickAccessVisibleState';
import TitleView from './TitleView';
const PluginView: VFC = () => {
const { plugins, activePlugin, setActivePlugin, closeActivePlugin } = useDeckyState();
const onStoreClick = () => {
Router.CloseSideMenus();
Router.NavigateToExternalWeb('http://127.0.0.1:1337/browser/redirect');
};
const { plugins, updates, activePlugin, setActivePlugin, closeActivePlugin } = useDeckyState();
const visible = useQuickAccessVisible();
if (activePlugin) {
return (
<div style={{ height: '100%' }}>
<div style={{ position: 'absolute', top: '3px', left: '16px', zIndex: 20 }}>
<DialogButton style={{ minWidth: 0, padding: '10px 12px' }} onClick={closeActivePlugin}>
<FaArrowLeft style={{ display: 'block' }} />
</DialogButton>
<Focusable onCancelButton={closeActivePlugin}>
<TitleView />
<div
className={joinClassNames(staticClasses.TabGroupPanel, scrollClasses.ScrollPanel, scrollClasses.ScrollY)}
style={{ height: '100%' }}
>
{(visible || activePlugin.alwaysRender) && activePlugin.content}
</div>
{activePlugin.content}
</div>
</Focusable>
);
}
return (
<PanelSection>
<div style={{ position: 'absolute', top: '3px', right: '16px', zIndex: 20 }}>
<DialogButton style={{ minWidth: 0, padding: '10px 12px' }} onClick={onStoreClick}>
<FaStore style={{ display: 'block' }} />
</DialogButton>
<>
<TitleView />
<div className={joinClassNames(staticClasses.TabGroupPanel, scrollClasses.ScrollPanel, scrollClasses.ScrollY)}>
<PanelSection>
{plugins
.filter((p) => p.content)
.map(({ name, icon }) => (
<PanelSectionRow key={name}>
<ButtonItem layout="below" onClick={() => setActivePlugin(name)}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
{icon}
<div>{name}</div>
<NotificationBadge show={updates?.has(name)} style={{ top: '-5px', right: '-5px' }} />
</div>
</ButtonItem>
</PanelSectionRow>
))}
</PanelSection>
</div>
{plugins.map(({ name, icon }) => (
<PanelSectionRow key={name}>
<ButtonItem layout="below" onClick={() => setActivePlugin(name)}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<div>{icon}</div>
<div>{name}</div>
</div>
</ButtonItem>
</PanelSectionRow>
))}
</PanelSection>
</>
);
};
@@ -0,0 +1,21 @@
import { FC, createContext, useContext, useState } from 'react';
const QuickAccessVisibleState = createContext<boolean>(true);
export const useQuickAccessVisible = () => useContext(QuickAccessVisibleState);
export const QuickAccessVisibleStateProvider: FC<{ initial: boolean; setter: ((val: boolean) => {}[]) | never[] }> = ({
children,
initial,
setter,
}) => {
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;
if (initial != prev) {
setPrev(initial);
setVisible(initial);
}
return <QuickAccessVisibleState.Provider value={visible}>{children}</QuickAccessVisibleState.Provider>;
};
+45 -6
View File
@@ -1,18 +1,57 @@
import { staticClasses } from 'decky-frontend-lib';
import { VFC } from 'react';
import { DialogButton, Focusable, Router, staticClasses } from 'decky-frontend-lib';
import { CSSProperties, VFC } from 'react';
import { FaArrowLeft, FaCog, FaStore } from 'react-icons/fa';
import { useDeckyState } from './DeckyState';
const titleStyles: CSSProperties = {
display: 'flex',
paddingTop: '3px',
paddingRight: '16px',
};
const TitleView: VFC = () => {
const { activePlugin } = useDeckyState();
const { activePlugin, closeActivePlugin } = useDeckyState();
const onSettingsClick = () => {
Router.CloseSideMenus();
Router.Navigate('/decky/settings');
};
const onStoreClick = () => {
Router.CloseSideMenus();
Router.Navigate('/decky/store');
};
if (activePlugin === null) {
return <div className={staticClasses.Title}>Decky</div>;
return (
<Focusable style={titleStyles} className={staticClasses.Title}>
<DialogButton
style={{ height: '28px', width: '40px', minWidth: 0, padding: '10px 12px' }}
onClick={onSettingsClick}
>
<FaCog style={{ marginTop: '-4px', display: 'block' }} />
</DialogButton>
<div style={{ marginRight: 'auto', flex: 0.9 }}>Decky</div>
<DialogButton
style={{ height: '28px', width: '40px', minWidth: 0, padding: '10px 12px' }}
onClick={onStoreClick}
>
<FaStore style={{ marginTop: '-4px', display: 'block' }} />
</DialogButton>
</Focusable>
);
}
return (
<div className={staticClasses.Title} style={{ paddingLeft: '60px' }}>
{activePlugin.name}
<div className={staticClasses.Title} style={titleStyles}>
<DialogButton
style={{ height: '28px', width: '40px', minWidth: 0, padding: '10px 12px' }}
onClick={closeActivePlugin}
>
<FaArrowLeft style={{ marginTop: '-4px', display: 'block' }} />
</DialogButton>
<div style={{ flex: 0.9 }}>{activePlugin.name}</div>
</div>
);
};
+47
View File
@@ -0,0 +1,47 @@
import { ToastData, findModule, joinClassNames } from 'decky-frontend-lib';
import { FunctionComponent } from 'react';
interface ToastProps {
toast: ToastData;
}
export const toastClasses = findModule((mod) => {
if (typeof mod !== 'object') return false;
if (mod.ToastPlaceholder) {
return true;
}
return false;
});
const templateClasses = findModule((mod) => {
if (typeof mod !== 'object') return false;
if (mod.ShortTemplate) {
return true;
}
return false;
});
const Toast: FunctionComponent<ToastProps> = ({ toast }) => {
return (
<div
style={{ '--toast-duration': `${toast.duration}ms` } as React.CSSProperties}
onClick={toast.onClick}
className={joinClassNames(templateClasses.ShortTemplate, toast.className || '')}
>
{toast.logo && <div className={templateClasses.StandardLogoDimensions}>{toast.logo}</div>}
<div className={joinClassNames(templateClasses.Content, toast.contentClassName || '')}>
<div className={templateClasses.Header}>
{toast.icon && <div className={templateClasses.Icon}>{toast.icon}</div>}
<div className={templateClasses.Title}>{toast.title}</div>
</div>
<div className={templateClasses.Body}>{toast.body}</div>
</div>
</div>
);
};
export default Toast;
+38
View File
@@ -0,0 +1,38 @@
import { Focusable, SteamSpinner } from 'decky-frontend-lib';
import { FunctionComponent, ReactElement, ReactNode, Suspense } from 'react';
interface WithSuspenseProps {
children: ReactNode;
route?: boolean;
}
// Nice little wrapper around Suspense so we don't have to duplicate the styles and code for the loading spinner
const WithSuspense: FunctionComponent<WithSuspenseProps> = (props) => {
const propsCopy = { ...props };
delete propsCopy.children;
(props.children as ReactElement)?.props && Object.assign((props.children as ReactElement).props, propsCopy); // There is probably a better way to do this but valve does it this way so ¯\_(ツ)_/¯
return (
<Suspense
fallback={
<Focusable
// needed to enable focus ring so that the focus properly resets on load
onActivate={() => {}}
style={{
overflowY: 'scroll',
backgroundColor: 'transparent',
...(props.route && {
marginTop: '40px',
height: 'calc( 100% - 40px )',
}),
}}
>
<SteamSpinner />
</Focusable>
}
>
{props.children}
</Suspense>
);
};
export default WithSuspense;
@@ -0,0 +1,42 @@
import { ConfirmModal, Navigation, QuickAccessTab, Spinner, staticClasses } from 'decky-frontend-lib';
import { FC, useState } from 'react';
interface PluginInstallModalProps {
artifact: string;
version: string;
hash: string;
// reinstall: boolean;
onOK(): void;
onCancel(): void;
closeModal?(): void;
}
const PluginInstallModal: FC<PluginInstallModalProps> = ({ artifact, version, hash, onOK, onCancel, closeModal }) => {
const [loading, setLoading] = useState<boolean>(false);
return (
<ConfirmModal
bOKDisabled={loading}
closeModal={closeModal}
onOK={async () => {
setLoading(true);
await onOK();
setTimeout(() => Navigation.OpenQuickAccessMenu(QuickAccessTab.Decky), 250);
setTimeout(() => window.DeckyPluginLoader.checkPluginUpdates(), 1000);
}}
onCancel={async () => {
await onCancel();
}}
>
<div className={staticClasses.Title} style={{ flexDirection: 'column' }}>
{hash == 'False' ? <h3 style={{ color: 'red' }}>!!!!NO HASH PROVIDED!!!!</h3> : null}
<div style={{ flexDirection: 'row' }}>
{loading && <Spinner style={{ width: '20px' }} />} {loading ? 'Installing' : 'Install'} {artifact}
{version ? ' version ' + version : null}
{!loading && '?'}
</div>
</div>
</ConfirmModal>
);
};
export default PluginInstallModal;
@@ -0,0 +1,170 @@
// https://codesandbox.io/s/react-file-icon-colored-tmwut?file=/src/App.js
import { FileIconProps } from 'react-file-icon';
type T_FileExtList = string[];
const styleDef: [FileIconProps, T_FileExtList][] = [];
// video ////////////////////////////////////
const videoStyle = {
color: '#f00f0f',
};
const videoExtList = [
'avi',
'3g2',
'3gp',
'aep',
'asf',
'flv',
'm4v',
'mkv',
'mov',
'mp4',
'mpeg',
'mpg',
'ogv',
'pr',
'swfw',
'webm',
'wmv',
'swf',
'rm',
];
styleDef.push([videoStyle, videoExtList]);
// image ////////////////////////////////////
const imageStyle = {
color: '#d18f00',
};
const imageExtList = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tif', 'tiff'];
styleDef.push([imageStyle, imageExtList]);
// zip ////////////////////////////////////
const zipStyle = {
color: '#f7b500',
labelTextColor: '#000',
// glyphColor: "#de9400"
};
const zipExtList = ['zip', 'zipx', '7zip', 'tar', 'sitx', 'gz', 'rar'];
styleDef.push([zipStyle, zipExtList]);
// audio ////////////////////////////////////
const audioStyle = {
color: '#f00f0f',
};
const audioExtList = ['aac', 'aif', 'aiff', 'flac', 'm4a', 'mid', 'mp3', 'ogg', 'wav'];
styleDef.push([audioStyle, audioExtList]);
// text ////////////////////////////////////
const textStyle = {
color: '#ffffff',
glyphColor: '#787878',
};
const textExtList = ['cue', 'odt', 'md', 'rtf', 'txt', 'tex', 'wpd', 'wps', 'xlr', 'fodt'];
styleDef.push([textStyle, textExtList]);
// system ////////////////////////////////////
const systemStyle = {
color: '#111',
};
const systemExtList = ['exe', 'ini', 'dll', 'plist', 'sys'];
styleDef.push([systemStyle, systemExtList]);
// srcCode ////////////////////////////////////
const srcCodeStyle = {
glyphColor: '#787878',
color: '#ffffff',
};
const srcCodeExtList = [
'asp',
'aspx',
'c',
'cpp',
'cs',
'css',
'scss',
'py',
'json',
'htm',
'html',
'java',
'yml',
'php',
'js',
'ts',
'rb',
'jsx',
'tsx',
];
styleDef.push([srcCodeStyle, srcCodeExtList]);
// vector ////////////////////////////////////
const vectorStyle = {
color: '#ffe600',
};
const vectorExtList = ['dwg', 'dxf', 'ps', 'svg', 'eps'];
styleDef.push([vectorStyle, vectorExtList]);
// font ////////////////////////////////////
const fontStyle = {
color: '#555',
};
const fontExtList = ['fnt', 'ttf', 'otf', 'fon', 'eot', 'woff'];
styleDef.push([fontStyle, fontExtList]);
// objectModel ////////////////////////////////////
const objectModelStyle = {
color: '#bf6a02',
glyphColor: '#bf6a02',
};
const objectModelExtList = ['3dm', '3ds', 'max', 'obj', 'pkg'];
styleDef.push([objectModelStyle, objectModelExtList]);
// sheet ////////////////////////////////////
const sheetStyle = {
color: '#2a6e00',
};
const sheetExtList = ['csv', 'fods', 'ods', 'xlr'];
styleDef.push([sheetStyle, sheetExtList]);
// const defaultStyle: Record<string, FileIconProps> = {
// pdf: {
// glyphColor: "white",
// color: "#D93831"
// }
// };
//////////////////////////////////////////////////
function createStyleObj(extList: T_FileExtList, styleObj: Partial<FileIconProps>) {
return Object.fromEntries(
extList.map((ext) => {
return [ext, { ...styleObj, glyphColor: 'white' }];
}),
);
}
export const styleDefObj = styleDef.reduce((acc, [fileStyle, fileExtList]) => {
return { ...acc, ...createStyleObj(fileExtList, fileStyle) };
});
@@ -0,0 +1,160 @@
import { DialogButton, Focusable, SteamSpinner, TextField } from 'decky-frontend-lib';
import { useEffect } from 'react';
import { FunctionComponent, useState } from 'react';
import { FileIcon, defaultStyles } from 'react-file-icon';
import { FaArrowUp, FaFolder } from 'react-icons/fa';
import Logger from '../../../logger';
import { styleDefObj } from './iconCustomizations';
const logger = new Logger('FilePicker');
export interface FilePickerProps {
startPath: string;
includeFiles?: boolean;
regex?: RegExp;
onSubmit: (val: { path: string; realpath: string }) => void;
closeModal?: () => void;
}
interface File {
isdir: boolean;
name: string;
realpath: string;
}
interface FileListing {
realpath: string;
files: File[];
}
function getList(
path: string,
includeFiles: boolean = true,
): Promise<{ result: FileListing | string; success: boolean }> {
return window.DeckyPluginLoader.callServerMethod('filepicker_ls', { path, include_files: includeFiles });
}
const iconStyles = {
paddingRight: '10px',
width: '1em',
};
const FilePicker: FunctionComponent<FilePickerProps> = ({
startPath,
includeFiles = true,
regex,
onSubmit,
closeModal,
}) => {
if (startPath.endsWith('/')) startPath = startPath.substring(0, startPath.length - 1); // remove trailing path
const [path, setPath] = useState<string>(startPath);
const [listing, setListing] = useState<FileListing>({ files: [], realpath: path });
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
(async () => {
if (error) setError(null);
setLoading(true);
const listing = await getList(path, includeFiles);
if (!listing.success) {
setListing({ files: [], realpath: path });
setLoading(false);
setError(listing.result as string);
logger.error(listing.result);
return;
}
setLoading(false);
setListing(listing.result as FileListing);
logger.log('reloaded', path, listing);
})();
}, [path]);
return (
<div className="deckyFilePicker">
<Focusable style={{ display: 'flex', flexDirection: 'row', paddingBottom: '10px' }}>
<DialogButton
style={{
minWidth: 'unset',
width: '40px',
flexGrow: '0',
borderRadius: 'unset',
margin: '0',
padding: '10px',
}}
onClick={() => {
const newPathArr = path.split('/');
newPathArr.pop();
let newPath = newPathArr.join('/');
if (newPath == '') newPath = '/';
setPath(newPath);
}}
>
<FaArrowUp />
</DialogButton>
<div style={{ flexGrow: '1', width: '100%' }}>
<TextField
value={path}
onChange={(e) => {
e.target.value && setPath(e.target.value);
}}
style={{ height: '100%' }}
/>
</div>
</Focusable>
<Focusable style={{ display: 'flex', flexDirection: 'column', height: '60vh', overflow: 'scroll' }}>
{loading && <SteamSpinner style={{ height: '100%' }} />}
{!loading &&
listing.files
.filter((file) => (includeFiles || file.isdir) && (!regex || regex.test(file.name)))
.map((file) => {
let extension = file.realpath.split('.').pop() as string;
return (
<DialogButton
style={{ borderRadius: 'unset', margin: '0', padding: '10px' }}
onClick={() => {
const fullPath = `${path}${path.endsWith('/') ? '' : '/'}${file.name}`;
if (file.isdir) setPath(fullPath);
else {
onSubmit({ path: fullPath, realpath: file.realpath });
closeModal?.();
}
}}
>
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'flex-start' }}>
{file.isdir ? (
<FaFolder style={iconStyles} />
) : (
<div style={iconStyles}>
{file.realpath.includes('.') ? (
<FileIcon {...defaultStyles[extension]} {...styleDefObj[extension]} extension={''} />
) : (
<FileIcon />
)}
</div>
)}
{file.name}
</div>
</DialogButton>
);
})}
{error}
</Focusable>
{!loading && !error && !includeFiles && (
<DialogButton
className="Primary"
style={{ marginTop: '10px', alignSelf: 'flex-end' }}
onClick={() => {
onSubmit({ path, realpath: listing.realpath });
closeModal?.();
}}
>
Use this folder
</DialogButton>
)}
</div>
);
};
export default FilePicker;
@@ -0,0 +1 @@
This directory contains patches that replace Valve's broken file picker with ours.
@@ -0,0 +1,10 @@
import library from './library';
let patches: Function[] = [];
export function deinitFilepickerPatches() {
patches.forEach((unpatch) => unpatch());
}
export async function initFilepickerPatches() {
patches.push(await library());
}
@@ -0,0 +1,69 @@
import { Patch, findModuleChild, replacePatch, sleep } from 'decky-frontend-lib';
import Logger from '../../../../logger';
const logger = new Logger('LibraryPatch');
declare global {
interface Window {
SteamClient: any;
appDetailsStore: any;
}
}
let patch: Patch;
function rePatch() {
// If you patch anything on SteamClient within the first few seconds of the client having loaded it will get redefined for some reason, so repatch any of these changes that occur with History.listen or an interval
patch = replacePatch(window.SteamClient.Apps, 'PromptToChangeShortcut', async ([appid]: number[]) => {
try {
const details = window.appDetailsStore.GetAppDetails(appid);
logger.debug('game details', details);
// strShortcutStartDir
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) {
logger.error(e);
}
});
}
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();
}
});
return () => {
unlisten();
patch.unpatch();
};
} catch (e) {
logger.error('Error patching library file picker', e);
}
return () => {};
}
@@ -0,0 +1,21 @@
import { Focusable, updaterFieldClasses } from 'decky-frontend-lib';
import { FunctionComponent, ReactNode } from 'react';
interface InlinePatchNotesProps {
date: ReactNode;
title: string;
children: ReactNode;
onClick?: () => void;
}
const InlinePatchNotes: FunctionComponent<InlinePatchNotesProps> = ({ date, title, children, onClick }) => {
return (
<Focusable className={updaterFieldClasses.PatchNotes} onActivate={onClick}>
<div className={updaterFieldClasses.PostedTime}>{date}</div>
<div className={updaterFieldClasses.EventDetailTitle}>{title}</div>
<div className={updaterFieldClasses.EventDetailsBody}>{children}</div>
</Focusable>
);
};
export default InlinePatchNotes;
@@ -0,0 +1,39 @@
import { SidebarNavigation } from 'decky-frontend-lib';
import { lazy } from 'react';
import { useSetting } from '../../utils/hooks/useSetting';
import WithSuspense from '../WithSuspense';
import GeneralSettings from './pages/general';
import PluginList from './pages/plugin_list';
const DeveloperSettings = lazy(() => import('./pages/developer'));
export default function SettingsPage() {
const [isDeveloper, setIsDeveloper] = useSetting<boolean>('developer.enabled', false);
const pages = [
{
title: 'General',
content: <GeneralSettings isDeveloper={isDeveloper} setIsDeveloper={setIsDeveloper} />,
route: '/decky/settings/general',
},
{
title: 'Plugins',
content: <PluginList />,
route: '/decky/settings/plugins',
},
];
if (isDeveloper)
pages.push({
title: 'Developer',
content: (
<WithSuspense>
<DeveloperSettings />
</WithSuspense>
),
route: '/decky/settings/developer',
});
return <SidebarNavigation title="Decky Settings" showTitle pages={pages} />;
}
@@ -0,0 +1,84 @@
import { Field, Focusable, 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';
export default function DeveloperSettings() {
const [enableValveInternal, setEnableValveInternal] = useSetting<boolean>('developer.valve_internal', false);
const [reactDevtoolsEnabled, setReactDevtoolsEnabled] = useSetting<boolean>('developer.rdt.enabled', false);
const [reactDevtoolsIP, setReactDevtoolsIP] = useSetting<string>('developer.rdt.ip', '');
const textRef = useRef<HTMLDivElement>(null);
return (
<>
<Field
label="Enable Valve Internal"
description={
<span style={{ whiteSpace: 'pre-line' }}>
Enables the Valve internal developer menu.{' '}
<span style={{ color: 'red' }}>Do not touch anything in this menu unless you know what it does.</span>
</span>
}
icon={<FaSteamSymbol style={{ display: 'block' }} />}
>
<Toggle
value={enableValveInternal}
onChange={(toggleValue) => {
setEnableValveInternal(toggleValue);
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
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>
</>
);
}
@@ -0,0 +1,41 @@
import { Dropdown, Field } from 'decky-frontend-lib';
import { FunctionComponent } from 'react';
import Logger from '../../../../logger';
import { callUpdaterMethod } from '../../../../updater';
import { useSetting } from '../../../../utils/hooks/useSetting';
const logger = new Logger('BranchSelect');
enum UpdateBranch {
Stable,
Prerelease,
// Testing,
}
const BranchSelect: FunctionComponent<{}> = () => {
const [selectedBranch, setSelectedBranch] = useSetting<UpdateBranch>('branch', UpdateBranch.Prerelease);
return (
// Returns numerical values from 0 to 2 (with current branch setup as of 8/28/22)
// 0 being stable, 1 being pre-release and 2 being nightly
<Field label="Update Channel">
<Dropdown
rgOptions={Object.values(UpdateBranch)
.filter((branch) => typeof branch == 'string')
.map((branch) => ({
label: branch,
data: UpdateBranch[branch],
}))}
selectedOption={selectedBranch}
onChange={async (newVal) => {
await setSelectedBranch(newVal.data);
callUpdaterMethod('check_for_updates');
logger.log('switching branches!');
}}
/>
</Field>
);
};
export default BranchSelect;
@@ -0,0 +1,29 @@
import { Field, Toggle } from 'decky-frontend-lib';
import { FaBug } from 'react-icons/fa';
import { useSetting } from '../../../../utils/hooks/useSetting';
export default function RemoteDebuggingSettings() {
const [allowRemoteDebugging, setAllowRemoteDebugging] = useSetting<boolean>('cef_forward', false);
return (
<Field
label="Allow Remote CEF Debugging"
description={
<span style={{ whiteSpace: 'pre-line' }}>
Allow unauthenticated access to the CEF debugger to anyone in your network
</span>
}
icon={<FaBug style={{ display: 'block' }} />}
>
<Toggle
value={allowRemoteDebugging || false}
onChange={(toggleValue) => {
setAllowRemoteDebugging(toggleValue);
if (toggleValue) window.DeckyPluginLoader.callServerMethod('allow_remote_debugging');
else window.DeckyPluginLoader.callServerMethod('disallow_remote_debugging');
}}
/>
</Field>
);
}
@@ -0,0 +1,52 @@
import { Dropdown, Field, TextField } from 'decky-frontend-lib';
import { FunctionComponent } from 'react';
import { FaShapes } from 'react-icons/fa';
import Logger from '../../../../logger';
import { Store } from '../../../../store';
import { useSetting } from '../../../../utils/hooks/useSetting';
const logger = new Logger('StoreSelect');
const StoreSelect: FunctionComponent<{}> = () => {
const [selectedStore, setSelectedStore] = useSetting<Store>('store', Store.Default);
const [selectedStoreURL, setSelectedStoreURL] = useSetting<string | null>('store-url', null);
// Returns numerical values from 0 to 2 (with current branch setup as of 8/28/22)
// 0 being Default, 1 being Testing and 2 being Custom
return (
<>
<Field label="Store Channel">
<Dropdown
rgOptions={Object.values(Store)
.filter((store) => typeof store == 'string')
.map((store) => ({
label: store,
data: Store[store],
}))}
selectedOption={selectedStore}
onChange={async (newVal) => {
await setSelectedStore(newVal.data);
logger.log('switching stores!');
}}
/>
</Field>
{selectedStore == Store.Custom && (
<Field
label="Custom Store"
indentLevel={1}
description={
<TextField
label={'URL'}
value={selectedStoreURL || undefined}
onChange={(e) => setSelectedStoreURL(e?.target.value || null)}
/>
}
icon={<FaShapes style={{ display: 'block' }} />}
></Field>
)}
</>
);
};
export default StoreSelect;
@@ -0,0 +1,165 @@
import {
Carousel,
DialogButton,
Field,
FocusRing,
Focusable,
ProgressBarWithInfo,
Spinner,
showModal,
} from 'decky-frontend-lib';
import { useCallback } from 'react';
import { Suspense, lazy } from 'react';
import { useEffect, useState } from 'react';
import { FaArrowDown } from 'react-icons/fa';
import { VerInfo, callUpdaterMethod, finishUpdate } from '../../../../updater';
import { findSP } from '../../../../utils/windows';
import { useDeckyState } from '../../../DeckyState';
import InlinePatchNotes from '../../../patchnotes/InlinePatchNotes';
import WithSuspense from '../../../WithSuspense';
const MarkdownRenderer = lazy(() => import('../../../Markdown'));
function PatchNotesModal({ versionInfo, closeModal }: { versionInfo: VerInfo | null; closeModal?: () => {} }) {
const SP = findSP();
return (
<Focusable onCancelButton={closeModal}>
<FocusRing>
<Carousel
fnItemRenderer={(id: number) => (
<Focusable
style={{
marginTop: '40px',
height: 'calc( 100% - 40px )',
overflowY: 'scroll',
display: 'flex',
justifyContent: 'center',
margin: '40px',
}}
>
<div>
<h1>{versionInfo?.all?.[id]?.name}</h1>
{versionInfo?.all?.[id]?.body ? (
<WithSuspense>
<MarkdownRenderer onDismiss={closeModal}>{versionInfo.all[id].body}</MarkdownRenderer>
</WithSuspense>
) : (
'no patch notes for this version'
)}
</div>
</Focusable>
)}
fnGetId={(id) => id}
nNumItems={versionInfo?.all?.length}
nHeight={SP.innerHeight - 40}
nItemHeight={SP.innerHeight - 40}
nItemMarginX={0}
initialColumn={0}
autoFocus={true}
fnGetColumnWidth={() => SP.innerWidth}
name="Decky Updates"
/>
</FocusRing>
</Focusable>
);
}
export default function UpdaterSettings() {
const { isLoaderUpdating, setIsLoaderUpdating, versionInfo, setVersionInfo } = useDeckyState();
const [checkingForUpdates, setCheckingForUpdates] = useState<boolean>(false);
const [updateProgress, setUpdateProgress] = useState<number>(-1);
const [reloading, setReloading] = useState<boolean>(false);
useEffect(() => {
window.DeckyUpdater = {
updateProgress: (i) => {
setUpdateProgress(i);
setIsLoaderUpdating(true);
},
finish: async () => {
setUpdateProgress(0);
setReloading(true);
await finishUpdate();
},
};
}, []);
const showPatchNotes = useCallback(() => {
showModal(<PatchNotesModal versionInfo={versionInfo} />);
}, [versionInfo]);
return (
<>
<Field
onOptionsActionDescription={versionInfo?.all ? 'Patch Notes' : undefined}
onOptionsButton={versionInfo?.all ? showPatchNotes : undefined}
label="Updates"
description={
versionInfo && (
<span style={{ whiteSpace: 'pre-line' }}>{`Current version: ${versionInfo.current}\n${
versionInfo.updatable ? `Latest version: ${versionInfo.remote?.tag_name}` : ''
}`}</span>
)
}
icon={
!versionInfo ? (
<Spinner style={{ width: '1em', height: 20, display: 'block' }} />
) : (
<FaArrowDown style={{ display: 'block' }} />
)
}
>
{updateProgress == -1 && !isLoaderUpdating ? (
<DialogButton
disabled={!versionInfo?.updatable || checkingForUpdates}
onClick={
!versionInfo?.remote || versionInfo?.remote?.tag_name == versionInfo?.current
? async () => {
setCheckingForUpdates(true);
const res = (await callUpdaterMethod('check_for_updates')) as { result: VerInfo };
setVersionInfo(res.result);
setCheckingForUpdates(false);
}
: async () => {
setUpdateProgress(0);
callUpdaterMethod('do_update');
}
}
>
{checkingForUpdates
? 'Checking'
: !versionInfo?.remote || versionInfo?.remote?.tag_name == versionInfo?.current
? 'Check For Updates'
: 'Install Update'}
</DialogButton>
) : (
<ProgressBarWithInfo
layout="inline"
bottomSeparator="none"
nProgress={updateProgress}
indeterminate={reloading}
sOperationText={reloading ? 'Reloading' : 'Updating'}
/>
)}
</Field>
{versionInfo?.remote && (
<InlinePatchNotes
title={versionInfo?.remote.name}
date={new Intl.RelativeTimeFormat('en-US', {
numeric: 'auto',
}).format(
Math.ceil((new Date(versionInfo.remote.published_at).getTime() - new Date().getTime()) / 86400000),
'day',
)}
onClick={showPatchNotes}
>
<Suspense fallback={<Spinner style={{ width: '24', height: '24' }} />}>
<MarkdownRenderer>{versionInfo?.remote.body}</MarkdownRenderer>
</Suspense>
</InlinePatchNotes>
)}
</>
);
}
@@ -0,0 +1,49 @@
import { DialogButton, Field, TextField, Toggle } from 'decky-frontend-lib';
import { useState } from 'react';
import { FaShapes, FaTools } from 'react-icons/fa';
import { installFromURL } from '../../../../store';
import BranchSelect from './BranchSelect';
import RemoteDebuggingSettings from './RemoteDebugging';
import StoreSelect from './StoreSelect';
import UpdaterSettings from './Updater';
export default function GeneralSettings({
isDeveloper,
setIsDeveloper,
}: {
isDeveloper: boolean;
setIsDeveloper: (val: boolean) => void;
}) {
const [pluginURL, setPluginURL] = useState('');
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>
);
}
@@ -0,0 +1,66 @@
import { DialogButton, Focusable, Menu, MenuItem, showContextMenu } from 'decky-frontend-lib';
import { useEffect } from 'react';
import { FaDownload, FaEllipsisH } from 'react-icons/fa';
import { requestPluginInstall } from '../../../../store';
import { useDeckyState } from '../../../DeckyState';
export default function PluginList() {
const { plugins, updates } = useDeckyState();
useEffect(() => {
window.DeckyPluginLoader.checkPluginUpdates();
}, []);
if (plugins.length === 0) {
return (
<div>
<p>No plugins installed</p>
</div>
);
}
return (
<ul style={{ listStyleType: 'none' }}>
{plugins.map(({ name, version }) => {
const update = updates?.get(name);
return (
<li style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', paddingBottom: '10px' }}>
<span>
{name} {version}
</span>
<Focusable style={{ marginLeft: 'auto', boxShadow: 'none', display: 'flex', justifyContent: 'right' }}>
{update && (
<DialogButton
style={{ height: '40px', minWidth: '60px', marginRight: '10px' }}
onClick={() => requestPluginInstall(name, update)}
>
<div style={{ display: 'flex', flexDirection: 'row' }}>
Update to {update.name}
<FaDownload style={{ paddingLeft: '2rem' }} />
</div>
</DialogButton>
)}
<DialogButton
style={{ height: '40px', width: '40px', padding: '10px 12px', minWidth: '40px' }}
onClick={(e: MouseEvent) =>
showContextMenu(
<Menu label="Plugin Actions">
<MenuItem onSelected={() => window.DeckyPluginLoader.importPlugin(name, version)}>
Reload
</MenuItem>
<MenuItem onSelected={() => window.DeckyPluginLoader.uninstallPlugin(name)}>Uninstall</MenuItem>
</Menu>,
e.currentTarget ?? window,
)
}
>
<FaEllipsisH />
</DialogButton>
</Focusable>
</li>
);
})}
</ul>
);
}
@@ -0,0 +1,188 @@
import {
DialogButton,
Dropdown,
Focusable,
Navigation,
QuickAccessTab,
SingleDropdownOption,
SuspensefulImage,
joinClassNames,
staticClasses,
} from 'decky-frontend-lib';
import { FC, useRef, useState } from 'react';
import { StorePlugin, StorePluginVersion, requestPluginInstall } from '../../store';
interface PluginCardProps {
plugin: StorePlugin;
}
const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
const [selectedOption, setSelectedOption] = useState<number>(0);
const buttonRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
return (
<div
style={{
padding: '30px',
paddingTop: '10px',
paddingBottom: '10px',
}}
>
{/* TODO: abstract this messy focus hackiness into a custom component in lib */}
<Focusable
className="deckyStoreCard"
ref={containerRef}
onActivate={(_: CustomEvent) => {
buttonRef.current!.focus();
}}
onCancel={(_: CustomEvent) => {
if (containerRef.current!.querySelectorAll('* :focus').length === 0) {
Navigation.NavigateBack();
setTimeout(() => Navigation.OpenQuickAccessMenu(QuickAccessTab.Decky), 1000);
} else {
containerRef.current!.focus();
}
}}
style={{
display: 'flex',
flexDirection: 'column',
background: '#ACB2C924',
height: 'unset',
marginBottom: 'unset',
// boxShadow: var(--gpShadow-Medium);
scrollSnapAlign: 'start',
boxSizing: 'border-box',
}}
>
<div className="deckyStoreCardHeader" style={{ display: 'flex', alignItems: 'center' }}>
<div
style={{ fontSize: '18pt', padding: '10px' }}
className={joinClassNames(staticClasses.Text)}
// onClick={() => Router.NavigateToExternalWeb('https://github.com/' + plugin.artifact)}
>
{plugin.name}
</div>
</div>
<div
style={{
display: 'flex',
flexDirection: 'row',
}}
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"
style={{
width: '100%',
alignSelf: 'flex-end',
display: 'flex',
flexDirection: 'row',
}}
>
<Focusable
className="deckyStoreCardActions"
style={{
display: 'flex',
flexDirection: 'row',
width: '100%',
}}
>
<div
className="deckyStoreCardInstallButtonContainer"
style={{
flex: '1',
}}
>
<DialogButton
className="deckyStoreCardInstallButton"
ref={buttonRef}
onClick={() => requestPluginInstall(plugin.name, plugin.versions[selectedOption])}
>
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>
</div>
</Focusable>
</div>
);
};
export default PluginCard;
+53
View File
@@ -0,0 +1,53 @@
import { SteamSpinner } from 'decky-frontend-lib';
import { FC, useEffect, useState } from 'react';
import Logger from '../../logger';
import { StorePlugin, getPluginList } from '../../store';
import PluginCard from './PluginCard';
const logger = new Logger('FilePicker');
const StorePage: FC<{}> = () => {
const [data, setData] = useState<StorePlugin[] | null>(null);
useEffect(() => {
(async () => {
const res = await getPluginList();
logger.log('got data!', res);
setData(res);
})();
}, []);
return (
<div
style={{
marginTop: '40px',
height: 'calc( 100% - 40px )',
overflowY: 'scroll',
}}
>
<div
style={{
display: 'flex',
flexWrap: 'nowrap',
flexDirection: 'column',
height: '100%',
}}
>
{!data ? (
<div style={{ height: '100%' }}>
<SteamSpinner />
</div>
) : (
<div>
{data.map((plugin: StorePlugin) => (
<PluginCard plugin={plugin} />
))}
</div>
)}
</div>
</div>
);
};
export default StorePage;
+105
View File
@@ -0,0 +1,105 @@
import {
Navigation,
ReactRouter,
Router,
fakeRenderComponent,
findInReactTree,
findInTree,
findModule,
findModuleChild,
gamepadDialogClasses,
gamepadSliderClasses,
playSectionClasses,
quickAccessControlsClasses,
quickAccessMenuClasses,
scrollClasses,
scrollPanelClasses,
sleep,
staticClasses,
updaterFieldClasses,
} from 'decky-frontend-lib';
import { FaReact } from 'react-icons/fa';
import Logger from './logger';
import { getSetting } from './utils/settings';
const logger = new Logger('DeveloperMode');
let removeSettingsObserver: () => void = () => {};
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[
Object.getOwnPropertySymbols(settingsMod).find((x) => x.toString() == 'Symbol(mobx administration)') as any
].observe((e: any) => {
e.newValue.bIsValveEmail = true;
});
settingsMod.m_Settings.bIsValveEmail = true;
logger.log('Enabled Valve Internal menu');
} else {
removeSettingsObserver();
settingsMod.m_Settings.bIsValveEmail = false;
logger.log('Disabled Valve Internal menu');
}
}
export async function setShouldConnectToReactDevTools(enable: boolean) {
window.DeckyPluginLoader.toaster.toast({
title: (enable ? 'Enabling' : 'Disabling') + ' React DevTools',
body: 'Reloading in 5 seconds',
icon: <FaReact />,
});
await sleep(5000);
return enable
? window.DeckyPluginLoader.callServerMethod('enable_rdt')
: window.DeckyPluginLoader.callServerMethod('disable_rdt');
}
export async function startup() {
const isValveInternalEnabled = await getSetting('developer.valve_internal', false);
const isRDTEnabled = await getSetting('developer.rdt.enabled', false);
if (isValveInternalEnabled) setShowValveInternal(isValveInternalEnabled);
if ((isRDTEnabled && !window.deckyHasConnectedRDT) || (!isRDTEnabled && window.deckyHasConnectedRDT))
setShouldConnectToReactDevTools(isRDTEnabled);
logger.log('Exposing decky-frontend-lib APIs as DFL');
window.DFL = {
findModuleChild,
findModule,
Navigation,
Router,
ReactRouter,
ReactUtils: {
fakeRenderComponent,
findInReactTree,
findInTree,
},
classes: {
scrollClasses,
staticClasses,
playSectionClasses,
scrollPanelClasses,
updaterFieldClasses,
gamepadDialogClasses,
gamepadSliderClasses,
quickAccessMenuClasses,
quickAccessControlsClasses,
},
};
}
+31 -12
View File
@@ -1,25 +1,44 @@
import PluginLoader from './plugin-loader';
import { DeckyUpdater } from './updater';
declare global {
interface Window {
DeckyPluginLoader: PluginLoader;
DeckyUpdater?: DeckyUpdater;
importDeckyPlugin: Function;
syncDeckyPlugins: Function;
deckyHasLoaded: boolean;
deckyHasConnectedRDT?: boolean;
deckyAuthToken: string;
DFL?: any;
}
}
window.DeckyPluginLoader?.dismountAll();
(async () => {
window.deckyAuthToken = await fetch('http://127.0.0.1:1337/auth/token').then((r) => r.text());
window.DeckyPluginLoader = new PluginLoader();
window.importDeckyPlugin = function (name: string) {
window.DeckyPluginLoader?.importPlugin(name);
};
window.DeckyPluginLoader?.dismountAll();
window.DeckyPluginLoader?.deinit();
window.syncDeckyPlugins = async function () {
const plugins = await (await fetch('http://127.0.0.1:1337/plugins')).json();
for (const plugin of plugins) {
window.DeckyPluginLoader?.importPlugin(plugin);
}
};
window.DeckyPluginLoader = new PluginLoader();
window.DeckyPluginLoader.init();
window.importDeckyPlugin = function (name: string, version: string) {
window.DeckyPluginLoader?.importPlugin(name, version);
};
setTimeout(() => window.syncDeckyPlugins(), 5000);
window.syncDeckyPlugins = async function () {
const plugins = await (
await fetch('http://127.0.0.1:1337/plugins', {
credentials: 'include',
headers: { Authentication: window.deckyAuthToken },
})
).json();
for (const plugin of plugins) {
if (!window.DeckyPluginLoader.hasPlugin(plugin.name))
window.DeckyPluginLoader?.importPlugin(plugin.name, plugin.version);
}
window.DeckyPluginLoader.checkPluginUpdates();
};
setTimeout(() => window.syncDeckyPlugins(), 5000);
})();
+16 -2
View File
@@ -8,8 +8,18 @@ export const log = (name: string, ...args: any[]) => {
);
};
export const debug = (name: string, ...args: any[]) => {
console.debug(
`%c Decky %c ${name} %c`,
'background: #16a085; color: black;',
'background: #1abc9c; color: black;',
'color: blue;',
...args,
);
};
export const error = (name: string, ...args: any[]) => {
console.log(
console.error(
`%c Decky %c ${name} %c`,
'background: #16a085; color: black;',
'background: #FF0000;',
@@ -28,7 +38,11 @@ class Logger {
}
debug(...args: any[]) {
log(this.name, ...args);
debug(this.name, ...args);
}
error(...args: any[]) {
error(this.name, ...args);
}
}
+235
View File
@@ -0,0 +1,235 @@
import {
CustomMainMenuItem,
ItemPatch,
MainMenuItem,
OverlayPatch,
afterPatch,
findInReactTree,
sleep,
} from 'decky-frontend-lib';
import { FC } from 'react';
import { ReactNode, cloneElement, createElement } from 'react';
import { DeckyMenuState, DeckyMenuStateContextProvider, useDeckyMenuState } from './components/DeckyMenuState';
import Logger from './logger';
declare global {
interface Window {
__MENU_HOOK_INSTANCE: any;
}
}
class MenuHook extends Logger {
private menuRenderer?: any;
private originalRenderer?: any;
private menuState: DeckyMenuState = new DeckyMenuState();
constructor() {
super('MenuHook');
this.log('Initialized');
window.__MENU_HOOK_INSTANCE?.deinit?.();
window.__MENU_HOOK_INSTANCE = this;
}
init() {
const tree = (document.getElementById('root') as any)._reactRootContainer._internalRoot.current;
let outerMenuRoot: any;
const findMenuRoot = (currentNode: any, iters: number): any => {
if (iters >= 60) {
// currently 54
return null;
}
if (currentNode?.memoizedProps?.navID == 'MainNavMenuContainer') {
this.log(`Menu root was found in ${iters} recursion cycles`);
return currentNode;
}
if (currentNode.child) {
let node = findMenuRoot(currentNode.child, iters + 1);
if (node !== null) return node;
}
if (currentNode.sibling) {
let node = findMenuRoot(currentNode.sibling, iters + 1);
if (node !== null) return node;
}
return null;
};
(async () => {
outerMenuRoot = findMenuRoot(tree, 0);
while (!outerMenuRoot) {
this.error(
'Failed to find Menu root node, reattempting in 5 seconds. A developer may need to increase the recursion limit.',
);
await sleep(5000);
outerMenuRoot = findMenuRoot(tree, 0);
}
this.log('found outermenuroot', outerMenuRoot);
const menuRenderer = outerMenuRoot.return;
this.menuRenderer = menuRenderer;
this.originalRenderer = menuRenderer.type;
let toReplace = new Map<string, ReactNode>();
let alreadyPatched = new Map<string, { total: number; node: ReactNode }>();
let patchedInnerMenu: any;
let overlayComponentManager: any;
const DeckyOverlayComponentManager = () => {
const { overlayComponents } = useDeckyMenuState();
return <>{overlayComponents.values()}</>;
};
const DeckyInnerMenuWrapper = (props: { innerProps: any }) => {
const { overlayPatches } = useDeckyMenuState();
const rendererRet = this.originalRenderer(props.innerProps);
// Find the first array of children, this contains [mainmenu, overlay]
const childArray = findInReactTree(rendererRet, (x) => x?.[0]?.type);
// Insert the overlay components manager
if (!overlayComponentManager) {
overlayComponentManager = <DeckyOverlayComponentManager />;
}
childArray.push(overlayComponentManager);
// This must be cached in patchedInnerMenu to prevent re-renders
if (patchedInnerMenu) {
childArray[0].type = patchedInnerMenu;
} else {
afterPatch(childArray[0], 'type', (_, ret) => {
const { itemPatches, items } = useDeckyMenuState();
const itemList = ret.props.children;
// Add custom menu items
if (items.size > 0) {
const button = findInReactTree(ret.props.children, (x) =>
x?.type?.toString()?.includes('exactRouteMatch:'),
);
const MenuItemComponent: FC<MainMenuItem> = button.type;
items.forEach((item) => {
let realIndex = 0; // there are some non-item things in the array
let count = 0;
itemList.forEach((i: any) => {
if (count == item.index) return;
if (i?.type == MenuItemComponent) count++;
realIndex++;
});
itemList.splice(realIndex, 0, createElement(MenuItemComponent, item));
});
}
// Apply and revert patches
itemList.forEach((item: { props: MainMenuItem }, index: number) => {
if (!item?.props?.route) return;
const replaced = toReplace.get(item?.props?.route as string);
if (replaced) {
itemList[index] = replaced;
toReplace.delete(item?.props.route as string);
}
if (item?.props?.route && (itemPatches.has(item.props.route as string) || itemPatches.has('*'))) {
if (
item?.props?.route &&
alreadyPatched.has(item.props.route) &&
alreadyPatched.get(item.props.route)?.total ==
(itemPatches.get(item.props.route)?.size || 0) + (itemPatches.get('*')?.size || 0)
) {
const patched = alreadyPatched.get(item.props.route);
this.debug('found already patched', patched);
itemList[index] = patched?.node;
return;
}
toReplace.set(item?.props?.route as string, itemList[index]);
itemPatches.get(item.props.route as string)?.forEach((patch) => {
const oType = itemList[index].type;
itemList[index] = patch({
...cloneElement(itemList[index]),
type: (props: any) => createElement(oType, props),
});
});
itemPatches.get('*')?.forEach((patch) => {
const oType = itemList[index].type;
itemList[index] = patch({
...cloneElement(itemList[index]),
type: (props: any) => createElement(oType, props),
});
});
alreadyPatched.set(item.props.route, {
total: (itemPatches.get(item.props.route)?.size || 0) + (itemPatches.get('*')?.size || 0),
node: itemList[index],
});
}
});
return ret;
});
patchedInnerMenu = childArray[0].type;
}
// Apply patches to the overlay
if (childArray[1]) {
overlayPatches.forEach((patch) => (childArray[1] = patch(childArray[1])));
}
return rendererRet;
};
const DeckyOuterMenuWrapper = (props: any) => {
return (
<DeckyMenuStateContextProvider deckyMenuState={this.menuState}>
<DeckyInnerMenuWrapper innerProps={props} />
</DeckyMenuStateContextProvider>
);
};
menuRenderer.type = DeckyOuterMenuWrapper;
if (menuRenderer.alternate) {
menuRenderer.alternate.type = menuRenderer.type;
}
this.log('Finished initial injection');
})();
}
deinit() {
this.menuRenderer.type = this.originalRenderer;
this.menuRenderer.alternate.type = this.menuRenderer.type;
}
addItem(item: CustomMainMenuItem) {
return this.menuState.addItem(item);
}
addPatch(path: string, patch: ItemPatch) {
return this.menuState.addPatch(path, patch);
}
addOverlayPatch(patch: OverlayPatch) {
return this.menuState.addOverlayPatch(patch);
}
addOverlayComponent(component: ReactNode) {
return this.menuState.addOverlayComponent(component);
}
removePatch(path: string, patch: ItemPatch) {
return this.menuState.removePatch(path, patch);
}
removeItem(item: CustomMainMenuItem) {
return this.menuState.removeItem(item);
}
removeOverlayPatch(patch: OverlayPatch) {
return this.menuState.removeOverlayPatch(patch);
}
removeOverlayComponent(component: ReactNode) {
return this.menuState.removeOverlayComponent(component);
}
}
export default MenuHook;
+229 -47
View File
@@ -1,69 +1,164 @@
import { ModalRoot, showModal, staticClasses } from 'decky-frontend-lib';
import { FaPlug } from 'react-icons/fa';
import { ConfirmModal, ModalRoot, QuickAccessTab, Router, showModal, sleep, staticClasses } from 'decky-frontend-lib';
import { FC, lazy } from 'react';
import { FaCog, FaExclamationCircle, FaPlug } from 'react-icons/fa';
import { DeckyState, DeckyStateContextProvider } from './components/DeckyState';
import { DeckyState, DeckyStateContextProvider, useDeckyState } from './components/DeckyState';
import LegacyPlugin from './components/LegacyPlugin';
import { deinitFilepickerPatches, initFilepickerPatches } from './components/modals/filepicker/patches';
import PluginInstallModal from './components/modals/PluginInstallModal';
import NotificationBadge from './components/NotificationBadge';
import PluginView from './components/PluginView';
import TitleView from './components/TitleView';
import WithSuspense from './components/WithSuspense';
import Logger from './logger';
import MenuHook from './menu-hook';
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';
import Toaster from './toaster';
import { VerInfo, callUpdaterMethod } from './updater';
import { getSetting } from './utils/settings';
declare global {
interface Window {}
}
const StorePage = lazy(() => import('./components/store/Store'));
const SettingsPage = lazy(() => import('./components/settings'));
const FilePicker = lazy(() => import('./components/modals/filepicker'));
class PluginLoader extends Logger {
private plugins: Plugin[] = [];
private tabsHook: TabsHook = new TabsHook();
private tabsHook: TabsHook | OldTabsHook = document.title == 'SP' ? new OldTabsHook() : new TabsHook();
private menuHook: MenuHook = new MenuHook();
// private windowHook: WindowHook = new WindowHook();
private routerHook: RouterHook = new RouterHook();
public toaster: Toaster = new Toaster();
private deckyState: DeckyState = new DeckyState();
private reloadLock: boolean = false;
// stores a list of plugin names which requested to be reloaded
private pluginReloadQueue: string[] = [];
private pluginReloadQueue: { name: string; version?: string }[] = [];
constructor() {
super(PluginLoader.name);
this.tabsHook.init();
this.menuHook.init();
this.log('Initialized');
const TabBadge = () => {
const { updates, hasLoaderUpdate } = useDeckyState();
return <NotificationBadge show={(updates && updates.size > 0) || hasLoaderUpdate} />;
};
this.tabsHook.add({
id: 'main',
title: (
<DeckyStateContextProvider deckyState={this.deckyState}>
<TitleView />
</DeckyStateContextProvider>
),
id: QuickAccessTab.Decky,
title: null,
content: (
<DeckyStateContextProvider deckyState={this.deckyState}>
<PluginView />
</DeckyStateContextProvider>
),
icon: <FaPlug />,
icon: (
<DeckyStateContextProvider deckyState={this.deckyState}>
<FaPlug />
<TabBadge />
</DeckyStateContextProvider>
),
});
this.routerHook.addRoute('/decky/store', () => (
<WithSuspense route={true}>
<StorePage />
</WithSuspense>
));
this.routerHook.addRoute('/decky/settings', () => {
return (
<DeckyStateContextProvider deckyState={this.deckyState}>
<WithSuspense route={true}>
<SettingsPage />
</WithSuspense>
</DeckyStateContextProvider>
);
});
initSteamFixes();
initFilepickerPatches();
this.updateVersion();
}
public addPluginInstallPrompt(artifact: string, version: string, request_id: string) {
public async updateVersion() {
const versionInfo = (await callUpdaterMethod('get_version')).result as VerInfo;
this.deckyState.setVersionInfo(versionInfo);
return versionInfo;
}
public async notifyUpdates() {
const versionInfo = await this.updateVersion();
if (versionInfo?.remote && versionInfo?.remote?.tag_name != versionInfo?.current) {
this.toaster.toast({
title: 'Decky',
body: `Update to ${versionInfo?.remote?.tag_name} available!`,
onClick: () => Router.Navigate('/decky/settings'),
});
this.deckyState.setHasLoaderUpdate(true);
}
await sleep(7000);
await this.notifyPluginUpdates();
}
public async checkPluginUpdates() {
const updates = await checkForUpdates(this.plugins);
this.deckyState.setUpdates(updates);
return updates;
}
public async notifyPluginUpdates() {
const updates = await this.checkPluginUpdates();
if (updates?.size > 0) {
this.toaster.toast({
title: 'Decky',
body: `Updates available for ${updates.size} plugin${updates.size > 1 ? 's' : ''}!`,
onClick: () => Router.Navigate('/decky/settings/plugins'),
});
}
}
public addPluginInstallPrompt(artifact: string, version: string, request_id: string, hash: string) {
showModal(
<ModalRoot
onOK={() => {
console.log('ok');
this.callServerMethod('confirm_plugin_install', { request_id });
<PluginInstallModal
artifact={artifact}
version={version}
hash={hash}
onOK={() => this.callServerMethod('confirm_plugin_install', { request_id })}
onCancel={() => this.callServerMethod('cancel_plugin_install', { request_id })}
/>,
);
}
public uninstallPlugin(name: string) {
showModal(
<ConfirmModal
onOK={async () => {
await this.callServerMethod('uninstall_plugin', { name });
}}
onCancel={() => {
console.log('nope');
this.callServerMethod('cancel_plugin_install', { request_id });
// do nothing
}}
>
<div className={staticClasses.Title}>
Install {artifact} version {version}?
<div className={staticClasses.Title} style={{ flexDirection: 'column' }}>
Uninstall {name}?
</div>
</ModalRoot>,
</ConfirmModal>,
);
}
public hasPlugin(name: string) {
return Boolean(this.plugins.find((plugin) => plugin.name == name));
}
public dismountAll() {
for (const plugin of this.plugins) {
this.log(`Dismounting ${plugin.name}`);
@@ -71,45 +166,96 @@ class PluginLoader extends Logger {
}
}
public async importPlugin(name: string) {
try {
if (this.reloadLock) {
this.log('Reload currently in progress, adding to queue', name);
this.pluginReloadQueue.push(name);
return;
}
public init() {
getSetting('developer.enabled', false).then((val) => {
if (val) import('./developer').then((developer) => developer.startup());
});
}
public deinit() {
this.routerHook.removeRoute('/decky/store');
this.routerHook.removeRoute('/decky/settings');
deinitSteamFixes();
deinitFilepickerPatches();
}
public unloadPlugin(name: string) {
const plugin = this.plugins.find((plugin) => plugin.name === name || plugin.name === name.replace('$LEGACY_', ''));
plugin?.onDismount?.();
this.plugins = this.plugins.filter((p) => p !== plugin);
this.deckyState.setPlugins(this.plugins);
}
public async importPlugin(name: string, version?: string | undefined) {
if (this.reloadLock) {
this.log('Reload currently in progress, adding to queue', name);
this.pluginReloadQueue.push({ name, version: version });
return;
}
try {
this.reloadLock = true;
this.log(`Trying to load ${name}`);
let find = this.plugins.find((x) => x.name == name);
if (find) this.plugins.splice(this.plugins.indexOf(find), 1);
this.unloadPlugin(name);
if (name.startsWith('$LEGACY_')) {
await this.importLegacyPlugin(name.replace('$LEGACY_', ''));
} else {
await this.importReactPlugin(name);
await this.importReactPlugin(name, version);
}
this.log(`Loaded ${name}`);
this.deckyState.setPlugins(this.plugins);
this.log(`Loaded ${name}`);
} catch (e) {
throw e;
} finally {
this.reloadLock = false;
const nextPlugin = this.pluginReloadQueue.shift();
if (nextPlugin) {
this.importPlugin(nextPlugin);
this.importPlugin(nextPlugin.name, nextPlugin.version);
}
}
}
private async importReactPlugin(name: string) {
let res = await fetch(`http://127.0.0.1:1337/plugins/${name}/frontend_bundle`);
private async importReactPlugin(name: string, version?: string) {
let res = await fetch(`http://127.0.0.1:1337/plugins/${name}/frontend_bundle`, {
credentials: 'include',
headers: {
Authentication: window.deckyAuthToken,
},
});
if (res.ok) {
let content = await eval(await res.text())(this.createPluginAPI(name));
this.plugins.push({
name: name,
icon: content.icon,
content: content.content,
});
try {
let plugin_export = await eval(await res.text());
let plugin = plugin_export(this.createPluginAPI(name));
this.plugins.push({
...plugin,
name: name,
version: version,
});
} catch (e) {
this.error('Error loading plugin ' + name, e);
const TheError: FC<{}> = () => (
<>
Error:{' '}
<pre>
<code>{e instanceof Error ? e.stack : JSON.stringify(e)}</code>
</pre>
<>
Please go to <FaCog style={{ display: 'inline' }} /> in the Decky menu if you need to uninstall this
plugin.
</>
</>
);
this.plugins.push({
name: name,
version: version,
content: <TheError />,
icon: <FaExclamationCircle />,
});
this.toaster.toast({ title: 'Error loading ' + name, body: '' + e, icon: <FaExclamationCircle /> });
}
} else throw new Error(`${name} frontend_bundle not OK`);
}
@@ -125,8 +271,10 @@ class PluginLoader extends Logger {
async callServerMethod(methodName: string, args = {}) {
const response = await fetch(`http://127.0.0.1:1337/methods/${methodName}`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Authentication: window.deckyAuthToken,
},
body: JSON.stringify(args),
});
@@ -134,15 +282,49 @@ class PluginLoader extends Logger {
return response.json();
}
openFilePicker(
startPath: string,
includeFiles?: boolean,
regex?: RegExp,
): Promise<{ path: string; realpath: string }> {
return new Promise((resolve, reject) => {
const Content = ({ closeModal }: { closeModal?: () => void }) => (
// Purposely outside of the FilePicker component as lazy-loaded ModalRoots don't focus correctly
<ModalRoot
onCancel={() => {
reject('User canceled');
closeModal?.();
}}
>
<WithSuspense>
<FilePicker
startPath={startPath}
includeFiles={includeFiles}
regex={regex}
onSubmit={resolve}
closeModal={closeModal}
/>
</WithSuspense>
</ModalRoot>
);
showModal(<Content />);
});
}
createPluginAPI(pluginName: string) {
return {
menuHook: this.menuHook,
routerHook: this.routerHook,
toaster: this.toaster,
callServerMethod: this.callServerMethod,
openFilePicker: this.openFilePicker,
async callPluginMethod(methodName: string, args = {}) {
const response = await fetch(`http://127.0.0.1:1337/plugins/${pluginName}/methods/${methodName}`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Authentication: window.deckyAuthToken,
},
body: JSON.stringify({
args,
@@ -152,7 +334,7 @@ class PluginLoader extends Logger {
return response.json();
},
fetchNoCors(url: string, request: any = {}) {
let args = { method: 'POST', headers: {}, body: '' };
let args = { method: 'POST', headers: {} };
const req = { ...args, ...request, url, data: request.body };
return this.callServerMethod('http_request', req);
},
+5 -3
View File
@@ -1,6 +1,8 @@
export interface Plugin {
name: any;
content: any;
icon: any;
name: string;
version?: string;
icon: JSX.Element;
content?: JSX.Element;
onDismount?(): void;
alwaysRender?: boolean;
}
+122 -27
View File
@@ -1,10 +1,16 @@
import { afterPatch, findModuleChild, unpatch } from 'decky-frontend-lib';
import { ReactElement, createElement, memo } from 'react';
import { Patch, afterPatch, findModuleChild } from 'decky-frontend-lib';
import { FC, ReactElement, ReactNode, cloneElement, createElement, memo } from 'react';
import type { Route } from 'react-router';
import {
DeckyGlobalComponentsState,
DeckyGlobalComponentsStateContextProvider,
useDeckyGlobalComponentsState,
} from './components/DeckyGlobalComponentsState';
import {
DeckyRouterState,
DeckyRouterStateContextProvider,
RoutePatch,
RouterEntry,
useDeckyRouterState,
} from './components/DeckyRouterState';
@@ -21,6 +27,10 @@ class RouterHook extends Logger {
private memoizedRouter: any;
private gamepadWrapper: any;
private routerState: DeckyRouterState = new DeckyRouterState();
private globalComponentsState: DeckyGlobalComponentsState = new DeckyGlobalComponentsState();
private wrapperPatch: Patch;
private routerPatch?: Patch;
public routes?: any[];
constructor() {
super('RouterHook');
@@ -38,37 +48,91 @@ class RouterHook extends Logger {
});
let Route: new () => Route;
const DeckyWrapper = ({ children }: { children: ReactElement }) => {
const { routes } = useDeckyRouterState();
const routerIndex = children.props.children[0].props.children.length - 1;
if (
!children.props.children[0].props.children[routerIndex].length ||
children.props.children[0].props.children !== routes.size
) {
const newRouterArray: ReactElement[] = [];
routes.forEach(({ component, props }, path) => {
newRouterArray.push(
<Route path={path} {...props}>
{createElement(component)}
</Route>,
);
});
children.props.children[0].props.children[routerIndex] = newRouterArray;
// Used to store the new replicated routes we create to allow routes to be unpatched.
const processList = (
routeList: any[],
routes: Map<string, RouterEntry> | null,
routePatches: Map<string, Set<RoutePatch>>,
save: boolean,
) => {
this.debug('Route list: ', routeList);
if (save) this.routes = routeList;
let routerIndex = routeList.length;
if (routes) {
if (!routeList[routerIndex - 1]?.length || routeList[routerIndex - 1]?.length !== routes.size) {
if (routeList[routerIndex - 1]?.length && routeList[routerIndex - 1].length !== routes.size) routerIndex--;
const newRouterArray: ReactElement[] = [];
routes.forEach(({ component, props }, path) => {
newRouterArray.push(
<Route path={path} {...props}>
{createElement(component)}
</Route>,
);
});
routeList[routerIndex] = newRouterArray;
}
}
routeList.forEach((route: Route, index: number) => {
const replaced = toReplace.get(route?.props?.path as string);
if (replaced) {
routeList[index].props.children = replaced;
toReplace.delete(route?.props?.path as string);
}
if (route?.props?.path && routePatches.has(route.props.path as string)) {
toReplace.set(
route?.props?.path as string,
// @ts-ignore
routeList[index].props.children,
);
routePatches.get(route.props.path as string)?.forEach((patch) => {
const oType = routeList[index].props.children.type;
routeList[index].props.children = patch({
...routeList[index].props,
children: {
...cloneElement(routeList[index].props.children),
type: (props) => createElement(oType, props),
},
}).children;
});
}
});
};
let toReplace = new Map<string, ReactNode>();
const DeckyWrapper = ({ children }: { children: ReactElement }) => {
const { routes, routePatches } = useDeckyRouterState();
const mainRouteList = children.props.children[0].props.children;
const ingameRouteList = children.props.children[1].props.children; // /appoverlay and /apprunning
processList(mainRouteList, routes, routePatches, true);
processList(ingameRouteList, null, routePatches, false);
this.debug('Rerendered routes list');
return children;
};
afterPatch(this.gamepadWrapper, 'render', (_: any, ret: any) => {
if (ret?.props?.children?.props?.children?.length == 5) {
let renderedComponents: ReactElement[] = [];
const DeckyGlobalComponentsWrapper = () => {
const { components } = useDeckyGlobalComponentsState();
if (renderedComponents.length != components.size) {
this.debug('Rerendering global components');
renderedComponents = Array.from(components.values()).map((GComponent) => <GComponent />);
}
return <>{renderedComponents}</>;
};
let globalComponents: any;
this.wrapperPatch = afterPatch(this.gamepadWrapper, 'render', (_: any, ret: any) => {
if (ret?.props?.children?.props?.children?.length == 5 || ret?.props?.children?.props?.children?.length == 4) {
const idx = ret?.props?.children?.props?.children?.length == 4 ? 1 : 2;
if (
ret.props.children.props.children[2]?.props?.children?.[0]?.type?.type
ret.props.children.props.children[idx]?.props?.children?.[0]?.type?.type
?.toString()
?.includes('GamepadUI.Settings.Root()')
) {
if (!this.router) {
this.router = ret.props.children.props.children[2]?.props?.children?.[0]?.type;
afterPatch(this.router, 'type', (_: any, ret: any) => {
this.router = ret.props.children.props.children[idx]?.props?.children?.[0]?.type;
this.routerPatch = afterPatch(this.router, 'type', (_: any, ret: any) => {
if (!Route)
Route = ret.props.children[0].props.children.find((x: any) => x.props.path == '/createaccount').type;
const returnVal = (
@@ -81,7 +145,18 @@ class RouterHook extends Logger {
this.memoizedRouter = memo(this.router.type);
this.memoizedRouter.isDeckyRouter = true;
}
ret.props.children.props.children[2].props.children[0].type = this.memoizedRouter;
if (!globalComponents) {
globalComponents = (
<DeckyGlobalComponentsStateContextProvider deckyGlobalComponentsState={this.globalComponentsState}>
<DeckyGlobalComponentsWrapper />
</DeckyGlobalComponentsStateContextProvider>
);
}
ret.props.children.props.children.push(globalComponents);
ret.props.children.props.children[idx].props.children[0].type = this.memoizedRouter;
}
}
return ret;
@@ -92,9 +167,29 @@ class RouterHook extends Logger {
this.routerState.addRoute(path, component, props);
}
addPatch(path: string, patch: RoutePatch) {
return this.routerState.addPatch(path, patch);
}
addGlobalComponent(name: string, component: FC) {
this.globalComponentsState.addComponent(name, component);
}
removeGlobalComponent(name: string) {
this.globalComponentsState.removeComponent(name);
}
removePatch(path: string, patch: RoutePatch) {
this.routerState.removePatch(path, patch);
}
removeRoute(path: string) {
this.routerState.removeRoute(path);
}
deinit() {
unpatch(this.gamepadWrapper, 'render');
this.router && unpatch(this.router, 'type');
this.wrapperPatch.unpatch();
this.routerPatch?.unpatch();
}
}
+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(reloadFix());
fixes.push(await restartFix());
}
+14
View File
@@ -0,0 +1,14 @@
import Logger from '../logger';
const logger = new Logger('ReloadSteamFix');
export default function reloadFix() {
// Hack to unbreak the ui when reloading it
if (window.FocusNavController?.m_rgAllContexts?.length == 0) {
SteamClient.URL.ExecuteSteamURL('steam://open/settings');
logger.log('Applied UI reload fix.');
}
// This steamfix does not need to deinit.
return () => {};
}
+60
View File
@@ -0,0 +1,60 @@
import { Patch, findModuleChild, replacePatch, sleep } from 'decky-frontend-lib';
import Logger from '../logger';
const logger = new Logger('RestartSteamFix');
declare global {
interface Window {
SteamClient: any;
appDetailsStore: any;
}
}
let patch: Patch;
function rePatch() {
// If you patch anything on SteamClient within the first few seconds of the client having loaded it will get redefined for some reason, so repatch any of these changes that occur 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 () => {};
}
+97
View File
@@ -0,0 +1,97 @@
import { Plugin } from './plugin';
import { getSetting, setSetting } from './utils/settings';
export enum Store {
Default,
Testing,
Custom,
}
export interface StorePluginVersion {
name: string;
hash: string;
artifact: string | undefined | null;
}
export interface StorePlugin {
id: number;
name: string;
versions: StorePluginVersion[];
author: string;
description: string;
tags: string[];
image_url: string;
}
// name: version
export type PluginUpdateMapping = Map<string, StorePluginVersion>;
export async function getPluginList(): Promise<StorePlugin[]> {
let version = await window.DeckyPluginLoader.updateVersion();
let store = await getSetting<Store>('store', Store.Default);
let customURL = await getSetting<string>('store-url', 'https://plugins.deckbrew.xyz/plugins');
let storeURL;
if (!store) {
console.log('Could not get a default store, using Default.');
await setSetting('store-url', Store.Default);
return fetch('https://plugins.deckbrew.xyz/plugins', {
method: 'GET',
headers: {
'X-Decky-Version': version.current,
},
}).then((r) => r.json());
} else {
switch (+store) {
case Store.Default:
storeURL = 'https://plugins.deckbrew.xyz/plugins';
break;
case Store.Testing:
storeURL = 'https://testing.deckbrew.xyz/plugins';
break;
case Store.Custom:
storeURL = customURL;
break;
default:
console.error('Somehow you ended up without a standard URL, using the default URL.');
storeURL = 'https://plugins.deckbrew.xyz/plugins';
break;
}
return fetch(storeURL, {
method: 'GET',
headers: {
'X-Decky-Version': version.current,
},
}).then((r) => r.json());
}
}
export async function installFromURL(url: string) {
const splitURL = url.split('/');
await window.DeckyPluginLoader.callServerMethod('install_plugin', {
name: splitURL[splitURL.length - 1].replace('.zip', ''),
artifact: url,
});
}
export 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: artifactUrl,
version: selectedVer.name,
hash: selectedVer.hash,
});
}
export async function checkForUpdates(plugins: Plugin[]): Promise<PluginUpdateMapping> {
const serverData = await getPluginList();
const updateMap = new Map<string, StorePluginVersion>();
for (let plugin of plugins) {
const remotePlugin = serverData?.find((x) => x.name == plugin.name);
if (remotePlugin && remotePlugin.versions?.length > 0 && plugin.version != remotePlugin?.versions?.[0]?.name) {
updateMap.set(plugin.name, remotePlugin.versions[0]);
}
}
return updateMap;
}
+119
View File
@@ -0,0 +1,119 @@
// TabsHook for versions before the Desktop merge
import { Patch, afterPatch, sleep } from 'decky-frontend-lib';
import { memo } from 'react';
import NewTabsHook from './tabs-hook';
declare global {
interface Array<T> {
__filter: any;
}
}
const isTabsArray = (tabs: any) => {
const length = tabs.length;
return length >= 7 && tabs[length - 1]?.tab;
};
class TabsHook extends NewTabsHook {
// private keys = 7;
private quickAccess: any;
private tabRenderer: any;
private memoizedQuickAccess: any;
private cNode: any;
private qAPTree: any;
private rendererTree: any;
private cNodePatch?: Patch;
constructor() {
super();
this.log('Initialized stable TabsHook');
}
init() {
const self = this;
const tree = (document.getElementById('root') as any)._reactRootContainer._internalRoot.current;
let scrollRoot: any;
async function findScrollRoot(currentNode: any, iters: number): Promise<any> {
if (iters >= 30) {
self.error(
'Scroll root was not found before hitting the recursion limit, a developer will need to increase the limit.',
);
return null;
}
currentNode = currentNode?.child;
if (currentNode?.type?.prototype?.RemoveSmartScrollContainer) {
self.log(`Scroll root was found in ${iters} recursion cycles`);
return currentNode;
}
if (!currentNode) return null;
if (currentNode.sibling) {
let node = await findScrollRoot(currentNode.sibling, iters + 1);
if (node !== null) return node;
}
return await findScrollRoot(currentNode, iters + 1);
}
(async () => {
scrollRoot = await findScrollRoot(tree, 0);
while (!scrollRoot) {
this.log('Failed to find scroll root node, reattempting in 5 seconds');
await sleep(5000);
scrollRoot = await findScrollRoot(tree, 0);
}
let newQA: any;
let newQATabRenderer: any;
this.cNodePatch = afterPatch(scrollRoot.stateNode, 'render', (_: any, ret: any) => {
if (!this.quickAccess && ret.props.children.props.children[4]) {
this.quickAccess = ret?.props?.children?.props?.children[4].type;
newQA = (...args: any) => {
const ret = this.quickAccess.type(...args);
if (ret) {
if (!newQATabRenderer) {
this.tabRenderer = ret.props.children[1].children.type;
newQATabRenderer = (...qamArgs: any[]) => {
const oFilter = Array.prototype.filter;
Array.prototype.filter = function (...args: any[]) {
if (isTabsArray(this)) {
self.render(this, qamArgs[0].visible);
}
// @ts-ignore
return oFilter.call(this, ...args);
};
// TODO remove array hack entirely and use this instead const tabs = ret.props.children.props.children[0].props.children[1].props.children[0].props.children[0].props.tabs
const ret = this.tabRenderer(...qamArgs);
Array.prototype.filter = oFilter;
return ret;
};
}
this.rendererTree = ret.props.children[1].children;
ret.props.children[1].children.type = newQATabRenderer;
}
return ret;
};
this.memoizedQuickAccess = memo(newQA);
this.memoizedQuickAccess.isDeckyQuickAccess = true;
}
if (ret.props.children.props.children[4]) {
this.qAPTree = ret.props.children.props.children[4];
ret.props.children.props.children[4].type = this.memoizedQuickAccess;
}
return ret;
});
this.cNode = scrollRoot;
this.cNode.stateNode.forceUpdate();
this.log('Finished initial injection');
})();
}
deinit() {
this.cNodePatch?.unpatch();
if (this.qAPTree) this.qAPTree.type = this.quickAccess;
if (this.rendererTree) this.rendererTree.type = this.tabRenderer;
if (this.cNode) this.cNode.stateNode.forceUpdate();
}
}
export default TabsHook;
-69
View File
@@ -1,69 +0,0 @@
import Logger from './logger';
declare global {
interface Window {
__TABS_HOOK_INSTANCE: any;
}
interface Array<T> {
__filter: any;
}
}
const isTabsArray = (tabs: any) => {
const length = tabs.length;
return length === 7 && tabs[length - 1]?.key === 6 && tabs[length - 1]?.tab;
};
interface Tab {
id: string;
title: any;
content: any;
icon: any;
}
class TabsHook extends Logger {
// private keys = 7;
tabs: Tab[] = [];
constructor() {
super('TabsHook');
this.log('Initialized');
window.__TABS_HOOK_INSTANCE = this;
const self = this;
const filter = Array.prototype.__filter ?? Array.prototype.filter;
Array.prototype.__filter = filter;
Array.prototype.filter = function (...args: any[]) {
if (isTabsArray(this)) {
self.render(this);
}
// @ts-ignore
return filter.call(this, ...args);
};
}
add(tab: Tab) {
this.log('Adding tab', tab.id, 'to render array');
this.tabs.push(tab);
}
removeById(id: string) {
this.log('Removing tab', id);
this.tabs = this.tabs.filter((tab) => tab.id !== id);
}
render(existingTabs: any[]) {
for (const { title, icon, content, id } of this.tabs) {
existingTabs.push({
key: id,
title,
tab: icon,
panel: content,
});
}
}
}
export default TabsHook;
+151
View File
@@ -0,0 +1,151 @@
// TabsHook for versions after the Desktop merge
import { Patch, QuickAccessTab, afterPatch, findInReactTree, sleep } from 'decky-frontend-lib';
import { QuickAccessVisibleStateProvider } from './components/QuickAccessVisibleState';
import Logger from './logger';
declare global {
interface Window {
__TABS_HOOK_INSTANCE: any;
}
}
interface Tab {
id: QuickAccessTab | number;
title: any;
content: any;
icon: any;
}
class TabsHook extends Logger {
// private keys = 7;
tabs: Tab[] = [];
private qAMRoot?: any;
private qamPatch?: Patch;
constructor() {
super('TabsHook');
this.log('Initialized');
window.__TABS_HOOK_INSTANCE?.deinit?.();
window.__TABS_HOOK_INSTANCE = this;
}
init() {
const tree = (document.getElementById('root') as any)._reactRootContainer._internalRoot.current;
let qAMRoot: any;
const findQAMRoot = (currentNode: any, iters: number): any => {
if (iters >= 55) {
// currently 45
return null;
}
if (
typeof currentNode?.memoizedProps?.visible == 'boolean' &&
currentNode?.type?.toString()?.includes('QuickAccessMenuBrowserView')
) {
this.log(`QAM root was found in ${iters} recursion cycles`);
return currentNode;
}
if (currentNode.child) {
let node = findQAMRoot(currentNode.child, iters + 1);
if (node !== null) return node;
}
if (currentNode.sibling) {
let node = findQAMRoot(currentNode.sibling, iters + 1);
if (node !== null) return node;
}
return null;
};
(async () => {
qAMRoot = findQAMRoot(tree, 0);
while (!qAMRoot) {
this.error(
'Failed to find QAM root node, reattempting in 5 seconds. A developer may need to increase the recursion limit.',
);
await sleep(5000);
qAMRoot = findQAMRoot(tree, 0);
}
this.qAMRoot = qAMRoot;
let patchedInnerQAM: any;
this.qamPatch = afterPatch(qAMRoot.return, 'type', (_: any, ret: any) => {
try {
if (!qAMRoot?.child) {
qAMRoot = findQAMRoot(tree, 0);
this.qAMRoot = qAMRoot;
}
if (qAMRoot?.child && !qAMRoot?.child?.type?.decky) {
afterPatch(qAMRoot.child, 'type', (_: any, ret: any) => {
try {
const qamTabsRenderer = findInReactTree(ret, (x) => x?.props?.onFocusNavDeactivated);
if (patchedInnerQAM) {
qamTabsRenderer.type = patchedInnerQAM;
} else {
afterPatch(qamTabsRenderer, 'type', (innerArgs: any, ret: any) => {
const tabs = findInReactTree(ret, (x) => x?.props?.tabs);
this.render(tabs.props.tabs, innerArgs[0].visible);
return ret;
});
patchedInnerQAM = qamTabsRenderer.type;
}
} catch (e) {
this.error('Error patching QAM inner', e);
}
return ret;
});
qAMRoot.child.type.decky = true;
qAMRoot.child.alternate.type = qAMRoot.child.type;
}
} catch (e) {
this.error('Error patching QAM', e);
}
return ret;
});
if (qAMRoot.return.alternate) {
qAMRoot.return.alternate.type = qAMRoot.return.type;
}
this.log('Finished initial injection');
})();
}
deinit() {
this.qamPatch?.unpatch();
this.qAMRoot.return.alternate.type = this.qAMRoot.return.type;
}
add(tab: Tab) {
this.debug('Adding tab', tab.id, 'to render array');
this.tabs.push(tab);
}
removeById(id: number) {
this.debug('Removing tab', id);
this.tabs = this.tabs.filter((tab) => tab.id !== id);
}
render(existingTabs: any[], visible: boolean) {
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);
}
return;
}
for (const { title, icon, content, id } of this.tabs) {
existingTabs.push({
key: id,
title,
tab: icon,
decky: true,
panel: (
<QuickAccessVisibleStateProvider initial={visible} setter={[]}>
{content}
</QuickAccessVisibleStateProvider>
),
});
}
}
}
export default TabsHook;
+186
View File
@@ -0,0 +1,186 @@
import { Module, Patch, ToastData, afterPatch, findInReactTree, findModuleChild, sleep } from 'decky-frontend-lib';
import { ReactNode } from 'react';
import Toast from './components/Toast';
import Logger from './logger';
declare global {
interface Window {
__TOASTER_INSTANCE: any;
settingsStore: any;
NotificationStore: any;
}
}
class Toaster extends Logger {
// private routerHook: RouterHook;
// private toasterState: DeckyToasterState = new DeckyToasterState();
private node: any;
private rNode: any;
private audioModule: any;
private finishStartup?: () => void;
private ready: Promise<void> = new Promise((res) => (this.finishStartup = res));
private toasterPatch?: Patch;
constructor() {
super('Toaster');
// this.routerHook = routerHook;
window.__TOASTER_INSTANCE?.deinit?.();
window.__TOASTER_INSTANCE = this;
this.init();
}
async init() {
// this.routerHook.addGlobalComponent('DeckyToaster', () => (
// <DeckyToasterStateContextProvider deckyToasterState={this.toasterState}>
// <DeckyToaster />
// </DeckyToasterStateContextProvider>
// ));
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
return null;
}
if (currentNode?.memoizedProps?.className?.startsWith?.('toastmanager_ToastPlaceholder')) {
this.log(`Toaster root was found in ${iters} recursion cycles`);
return currentNode;
}
if (currentNode.sibling) {
let node = findToasterRoot(currentNode.sibling, iters + 1);
if (node !== null) return node;
}
if (currentNode.child) {
let node = findToasterRoot(currentNode.child, iters + 1);
if (node !== null) return node;
}
return null;
};
instance = findToasterRoot(tree, 0);
while (!instance) {
this.error(
'Failed to find Toaster root node, reattempting in 5 seconds. A developer may need to increase the recursion limit.',
);
await sleep(5000);
instance = findToasterRoot(tree, 0);
}
this.node = instance.return;
this.rNode = this.node.return;
let toast: any;
let renderedToast: ReactNode = null;
let innerPatched: any;
const repatch = () => {
if (this.node && !this.node.type.decky) {
this.toasterPatch = afterPatch(this.node, 'type', (_: any, ret: any) => {
const inner = findInReactTree(ret.props.children, (x) => x?.props?.onDismiss);
if (innerPatched) {
inner.type = innerPatched;
} else {
afterPatch(inner, 'type', (innerArgs: any, ret: any) => {
const currentToast = innerArgs[0]?.notification;
if (currentToast?.decky) {
if (currentToast == toast) {
ret.props.children = renderedToast;
} else {
toast = currentToast;
renderedToast = <Toast toast={toast.data} />;
ret.props.children = renderedToast;
}
} else {
toast = null;
renderedToast = null;
}
return ret;
});
innerPatched = inner.type;
}
return ret;
});
this.node.type.decky = true;
this.node.alternate.type = this.node.type;
}
};
const oRender = this.rNode.stateNode.__proto__.render;
let int: NodeJS.Timer | undefined;
this.rNode.stateNode.render = (...args: any[]) => {
const ret = oRender.call(this.rNode.stateNode, ...args);
if (ret && !this?.node?.return?.return) {
clearInterval(int);
int = setInterval(() => {
const n = findToasterRoot(tree, 0);
if (n?.return) {
clearInterval(int);
this.node = n.return;
this.rNode = this.node.return;
repatch();
} else {
this.error('Failed to re-grab Toaster node, trying again...');
}
}, 1200);
}
repatch();
return ret;
};
this.rNode.stateNode.shouldComponentUpdate = () => true;
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?.();
}
async toast(toast: ToastData) {
// toast.duration = toast.duration || 5e3;
// this.toasterState.addToast(toast);
await this.ready;
let toastData = {
nNotificationID: window.NotificationStore.m_nNextTestNotificationID++,
rtCreated: Date.now(),
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 (
(window.settingsStore.settings.bDisableAllToasts && !toast.critical) ||
(window.settingsStore.settings.bDisableToastsInGame &&
!toast.critical &&
window.NotificationStore.BIsUserInGame())
)
return;
if (toast.playSound) this.audioModule?.PlayNavSound(toast.sound);
if (toast.showToast) {
window.NotificationStore.m_rgNotificationToasts.push(toastData);
window.NotificationStore.DispatchNextToast();
}
}
deinit() {
this.toasterPatch?.unpatch();
this.node.alternate.type = this.node.type;
delete this.rNode.stateNode.render;
this.ready = new Promise((res) => (this.finishStartup = res));
// this.routerHook.removeGlobalComponent('DeckyToaster');
}
}
export default Toaster;
+47
View File
@@ -0,0 +1,47 @@
export enum Branches {
Release,
Prerelease,
// Testing,
}
export interface DeckyUpdater {
updateProgress: (val: number) => void;
finish: () => void;
}
export interface RemoteVerInfo {
assets: {
browser_download_url: string;
created_at: string;
}[];
name: string;
body: string;
prerelease: boolean;
published_at: string;
tag_name: string;
}
export interface VerInfo {
current: string;
remote: RemoteVerInfo | null;
all: RemoteVerInfo[] | null;
updatable: boolean;
}
export async function callUpdaterMethod(methodName: string, args = {}) {
const response = await fetch(`http://127.0.0.1:1337/updater/${methodName}`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Authentication: window.deckyAuthToken,
},
body: JSON.stringify(args),
});
return response.json();
}
export async function finishUpdate() {
callUpdaterMethod('do_restart');
}
+22
View File
@@ -0,0 +1,22 @@
import { useEffect, useState } from 'react';
import { getSetting, setSetting } from '../settings';
export function useSetting<T>(key: string, def: T): [value: T, setValue: (value: T) => Promise<void>] {
const [value, setValue] = useState(def);
useEffect(() => {
(async () => {
const res = await getSetting<T>(key, def);
setValue(res);
})();
}, []);
return [
value,
async (val: T) => {
setValue(val);
await setSetting(key, val);
},
];
}
+24
View File
@@ -0,0 +1,24 @@
interface GetSettingArgs<T> {
key: string;
default: T;
}
interface SetSettingArgs<T> {
key: string;
value: T;
}
export async function getSetting<T>(key: string, def: T): Promise<T> {
const res = (await window.DeckyPluginLoader.callServerMethod('get_setting', {
key,
default: def,
} as GetSettingArgs<T>)) as { result: T };
return res.result;
}
export async function setSetting<T>(key: string, value: T): Promise<void> {
await window.DeckyPluginLoader.callServerMethod('set_setting', {
key,
value,
} as SetSettingArgs<T>);
}
+7
View File
@@ -0,0 +1,7 @@
export function findSP(): Window {
// old (SP as host)
if (document.title == 'SP') return window;
// new (SP as popup)
return FocusNavController.m_ActiveContext.m_rgGamepadNavigationTrees.find((x: any) => x.m_ID == 'root_1_').Root
.Element.ownerDocument.defaultView;
}
+1 -1
View File
@@ -1,10 +1,10 @@
{
"compilerOptions": {
"outDir": "dist",
"module": "ESNext",
"target": "ES2020",
"jsx": "react",
"jsxFactory": "window.SP_REACT.createElement",
"jsxFragmentFactory": "window.SP_REACT.Fragment",
"declaration": false,
"moduleResolution": "node",
"noUnusedLocals": true,
+1
View File
@@ -2,3 +2,4 @@ aiohttp==3.8.1
aiohttp-jinja2==1.5.0
aiohttp_cors==0.7.0
watchdog==2.1.7
certifi==2022.6.15