Compare commits

...

54 Commits

Author SHA1 Message Date
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
47 changed files with 1810 additions and 646 deletions
+18 -16
View File
@@ -42,29 +42,33 @@ jobs:
- name: Checkout 🧰 - name: Checkout 🧰
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Set up NodeJS 17 💎 - name: Set up NodeJS 18 💎
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 17 node-version: 18
- name: Set up Python 3.10 🐍 - name: Set up Python 3.10.2 🐍
uses: actions/setup-python@v3 uses: actions/setup-python@v4
with: with:
python-version: "3.10" python-version: "3.10.2"
- name: Install Python dependencies ⬇️ - name: Install Python dependencies ⬇️
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install pyinstaller pip install pyinstaller==5.5
[ -f requirements.txt ] && pip install -r requirements.txt [ -f requirements.txt ] && pip install -r requirements.txt
- name: Install NodeJS dependencies ⬇️ - name: Install JS dependencies ⬇️
working-directory: ./frontend
run: | run: |
cd frontend npm i -g pnpm
npm i pnpm i --frozen-lockfile
npm run build
- name: Build 🛠️ - 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 run: pyinstaller --noconfirm --onefile --name "PluginLoader" --add-data ./backend/static:/static --add-data ./backend/legacy:/legacy ./backend/*.py
- name: Upload package artifact ⬆️ - name: Upload package artifact ⬆️
@@ -142,7 +146,7 @@ jobs:
fi fi
fi fi
echo "vOUT: v$OUT" echo "vOUT: v$OUT"
echo ::set-output name=tag_name::v$OUT echo tag_name=v$OUT >> $GITHUB_OUTPUT
- name: Push tag 📤 - name: Push tag 📤
uses: rickstaa/action-create-tag@v1.3.2 uses: rickstaa/action-create-tag@v1.3.2
@@ -224,14 +228,12 @@ jobs:
else else
printf "type not selected, defaulting to new pre-release only\n" printf "type not selected, defaulting to new pre-release only\n"
printf "bumping prerel\n" printf "bumping prerel\n"
OUT="$VERSION-pre" OUT=$(semver bump prerel "$VERSION")
printf "OUT: ${OUT}\n"
OUT=$(semver bump prerel "$OUT")
printf "OUT: ${OUT}\n" printf "OUT: ${OUT}\n"
fi fi
fi fi
printf "vOUT: v${OUT}\n" printf "vOUT: v${OUT}\n"
echo ::set-output name=tag_name::v$OUT echo tag_name=v$OUT >> $GITHUB_OUTPUT
- name: Push tag 📤 - name: Push tag 📤
uses: rickstaa/action-create-tag@v1.3.2 uses: rickstaa/action-create-tag@v1.3.2
+34 -7
View File
@@ -14,7 +14,9 @@
"label": "localrun", "label": "localrun",
"type": "shell", "type": "shell",
"group": "none", "group": "none",
"dependsOn" : ["buildall"], "dependsOn": [
"buildall"
],
"detail": "Check for local runs, create a plugins folder", "detail": "Check for local runs, create a plugins folder",
"command": "mkdir -p plugins", "command": "mkdir -p plugins",
"problemMatcher": [] "problemMatcher": []
@@ -48,6 +50,16 @@
"command": "cd frontend && pnpm i", "command": "cd frontend && pnpm i",
"problemMatcher": [] "problemMatcher": []
}, },
{
"script": "watch",
"type": "npm",
"path": "frontend",
"group": "build",
"problemMatcher": [],
"label": "watchfrontend",
"detail": "rollup -c -w",
"isBackground": true
},
{ {
"label": "buildfrontend", "label": "buildfrontend",
"type": "npm", "type": "npm",
@@ -55,8 +67,7 @@
"detail": "rollup -c", "detail": "rollup -c",
"script": "build", "script": "build",
"path": "frontend", "path": "frontend",
"problemMatcher": [], "problemMatcher": []
}, },
{ {
"label": "buildall", "label": "buildall",
@@ -95,7 +106,9 @@
"detail": "Run indev PluginLoader on Deck", "detail": "Run indev PluginLoader on Deck",
"type": "shell", "type": "shell",
"group": "none", "group": "none",
"dependsOn" : ["checkforsettings"], "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'", "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": [] "problemMatcher": []
}, },
@@ -108,6 +121,20 @@
"problemMatcher": [] "problemMatcher": []
}, },
// ALL-IN-ONES // 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", "label": "updateremote",
"detail": "Build and deploy", "detail": "Build and deploy",
@@ -115,7 +142,7 @@
"group": "none", "group": "none",
"dependsOn": [ "dependsOn": [
"buildall", "buildall",
"deploy", "deploy"
], ],
"problemMatcher": [] "problemMatcher": []
}, },
@@ -128,7 +155,7 @@
"isDefault": true "isDefault": true
}, },
"dependsOn": [ "dependsOn": [
"buildall", "buildfrontend",
"deploy", "deploy",
"runpydeck" "runpydeck"
], ],
@@ -152,4 +179,4 @@
"problemMatcher": [] "problemMatcher": []
} }
] ]
} }
+2 -2
View File
@@ -17,7 +17,7 @@
## 📖 About ## 📖 About
Decky Loader is a homebrew plugin launcher for the Steam Deck. It can be used to [stylize your menus](https://github.com/suchmememanyskill/SDH-CssLoader), [change system sounds](https://github.com/EMERALD0874/SDH-AudioLoader), [adjust your screen saturation](https://github.com/libvibrant/vibrantDeck), [change additional system settings](https://github.com/NGnius/PowerTools), and [more](https://beta.deckbrew.xyz/). 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/).
For more information about Decky Loader as well as documentation and development tools, please visit [our wiki](https://deckbrew.xyz). For more information about Decky Loader as well as documentation and development tools, please visit [our wiki](https://deckbrew.xyz).
@@ -33,7 +33,7 @@ For more information about Decky Loader as well as documentation and development
- Crankshaft is incompatible with Decky Loader. If you are using Crankshaft, please uninstall it before installing Decky Loader. - Crankshaft is incompatible with Decky Loader. If you are using Crankshaft, please uninstall it before installing Decky Loader.
- Syncthing may use port 8080 on Steam Deck, which Decky Loader needs to function. If you are using Syncthing as a service, please change its port to something else. - Syncthing may use port 8080 on Steam Deck, which Decky Loader needs to function. If you are using Syncthing as a service, please change its port to something else.
- If you are using any software that uses port 1337, please change its port to something else or uninstall it. - If you are using any software that uses port 1337 or 8080, please change its port to something else or uninstall it.
## 💾 Installation ## 💾 Installation
+16 -8
View File
@@ -16,7 +16,7 @@ from zipfile import ZipFile
# Local modules # Local modules
from helpers import get_ssl_context, get_user, get_user_group, download_remote_binary_to_path from helpers import get_ssl_context, get_user, get_user_group, download_remote_binary_to_path
from injector import get_tab, inject_to_tab from injector import get_gamepadui_tab
logger = getLogger("Browser") logger = getLogger("Browser")
@@ -57,7 +57,7 @@ class PluginBrowser:
if access(packageJsonPath, R_OK): if access(packageJsonPath, R_OK):
with open(packageJsonPath, 'r') as f: with open(packageJsonPath, 'r') as f:
packageJson = json.load(f) packageJson = json.load(f)
if len(packageJson["remote_binary"]) > 0: if "remote_binary" in packageJson and len(packageJson["remote_binary"]) > 0:
# create bin directory if needed. # create bin directory if needed.
rc=call(["chmod", "-R", "777", pluginBasePath]) rc=call(["chmod", "-R", "777", pluginBasePath])
if access(pluginBasePath, W_OK): if access(pluginBasePath, W_OK):
@@ -97,14 +97,14 @@ class PluginBrowser:
plugin = json.load(f) plugin = json.load(f)
if plugin['name'] == name: if plugin['name'] == name:
return path.join(self.plugin_path, folder) return str(path.join(self.plugin_path, folder))
except: except:
logger.debug(f"skipping {folder}") logger.debug(f"skipping {folder}")
async def uninstall_plugin(self, name): async def uninstall_plugin(self, name):
if self.loader.watcher: if self.loader.watcher:
self.loader.watcher.disabled = True self.loader.watcher.disabled = True
tab = await get_tab("SP") tab = await get_gamepadui_tab()
try: try:
logger.info("uninstalling " + name) logger.info("uninstalling " + name)
logger.info(" at dir " + self.find_plugin_folder(name)) logger.info(" at dir " + self.find_plugin_folder(name))
@@ -124,12 +124,15 @@ class PluginBrowser:
self.loader.watcher.disabled = False self.loader.watcher.disabled = False
async def _install(self, artifact, name, version, hash): async def _install(self, artifact, name, version, hash):
isInstalled = False
if self.loader.watcher: if self.loader.watcher:
self.loader.watcher.disabled = True self.loader.watcher.disabled = True
try: try:
await self.uninstall_plugin(name) pluginFolderPath = self.find_plugin_folder(name)
if pluginFolderPath:
isInstalled = True
except: except:
logger.error(f"Plugin {name} not installed, skipping uninstallation") logger.error(f"Failed to determine if {name} is already installed, continuing anyway.")
logger.info(f"Installing {name} (Version: {version})") logger.info(f"Installing {name} (Version: {version})")
async with ClientSession() as client: async with ClientSession() as client:
logger.debug(f"Fetching {artifact}") logger.debug(f"Fetching {artifact}")
@@ -139,6 +142,12 @@ class PluginBrowser:
data = await res.read() data = await res.read()
logger.debug(f"Read {len(data)} bytes") logger.debug(f"Read {len(data)} bytes")
res_zip = BytesIO(data) 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...") logger.debug("Unzipping...")
ret = self._unzip_to_plugin_dir(res_zip, name, hash) ret = self._unzip_to_plugin_dir(res_zip, name, hash)
if ret: if ret:
@@ -151,7 +160,6 @@ class PluginBrowser:
self.loader.plugins.pop(name, None) self.loader.plugins.pop(name, None)
await sleep(1) await sleep(1)
self.loader.import_plugin(path.join(plugin_dir, "main.py"), plugin_dir) self.loader.import_plugin(path.join(plugin_dir, "main.py"), plugin_dir)
# await inject_to_tab("SP", "window.syncDeckyPlugins()")
else: else:
logger.fatal(f"Failed Downloading Remote Binaries") logger.fatal(f"Failed Downloading Remote Binaries")
else: else:
@@ -164,7 +172,7 @@ class PluginBrowser:
async def request_plugin_install(self, artifact, name, version, hash): async def request_plugin_install(self, artifact, name, version, hash):
request_id = str(time()) request_id = str(time())
self.install_requests[request_id] = PluginInstallContext(artifact, name, version, hash) self.install_requests[request_id] = PluginInstallContext(artifact, name, version, hash)
tab = await get_tab("SP") tab = await get_gamepadui_tab()
await tab.open_websocket() await tab.open_websocket()
await tab.evaluate_js(f"DeckyPluginLoader.addPluginInstallPrompt('{name}', '{version}', '{request_id}', '{hash}')") await tab.evaluate_js(f"DeckyPluginLoader.addPluginInstallPrompt('{name}', '{version}', '{request_id}', '{hash}')")
+11 -11
View File
@@ -94,18 +94,18 @@ async def download_remote_binary_to_path(url, binHash, path) -> bool:
if os.access(os.path.dirname(path), os.W_OK): if os.access(os.path.dirname(path), os.W_OK):
async with ClientSession() as client: async with ClientSession() as client:
res = await client.get(url, ssl=get_ssl_context()) res = await client.get(url, ssl=get_ssl_context())
if res.status == 200: if res.status == 200:
data = BytesIO(await res.read()) data = BytesIO(await res.read())
remoteHash = sha256(data.getbuffer()).hexdigest() remoteHash = sha256(data.getbuffer()).hexdigest()
if binHash == remoteHash: if binHash == remoteHash:
data.seek(0) data.seek(0)
with open(path, 'wb') as f: with open(path, 'wb') as f:
f.write(data.getbuffer()) f.write(data.getbuffer())
rv = True rv = True
else:
raise Exception(f"Fatal Error: Hash Mismatch for remote binary {path}@{url}")
else: else:
rv = False raise Exception(f"Fatal Error: Hash Mismatch for remote binary {path}@{url}")
else:
rv = False
except: except:
rv = False rv = False
+352 -62
View File
@@ -1,20 +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 asyncio import sleep
from logging import debug, getLogger from logging import getLogger
from traceback import format_exc from traceback import format_exc
from typing import List
from aiohttp import ClientSession from aiohttp import ClientSession, WSMsgType
from aiohttp.client_exceptions import ClientConnectorError from aiohttp.client_exceptions import ClientConnectorError, ClientOSError
from asyncio.exceptions import TimeoutError
import uuid
BASE_ADDRESS = "http://localhost:8080" BASE_ADDRESS = "http://localhost:8080"
logger = getLogger("Injector") logger = getLogger("Injector")
class Tab: class Tab:
cmd_id = 0
def __init__(self, res) -> None: def __init__(self, res) -> None:
self.title = res["title"] self.title = res["title"]
self.id = res["id"] self.id = res["id"]
self.url = res["url"]
self.ws_url = res["webSocketDebuggerUrl"] self.ws_url = res["webSocketDebuggerUrl"]
self.websocket = None self.websocket = None
@@ -24,34 +31,318 @@ class Tab:
self.client = ClientSession() self.client = ClientSession()
self.websocket = await self.client.ws_connect(self.ws_url) 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 def listen_for_message(self):
async for message in self.websocket: 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): async def _send_devtools_cmd(self, dc, receive=True):
if self.websocket: if self.websocket:
self.cmd_id += 1
dc["id"] = self.cmd_id
await self.websocket.send_json(dc) 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") raise RuntimeError("Websocket not opened")
async def evaluate_js(self, js, run_async=False, manage_socket=True, get_result=True): async def evaluate_js(self, js, run_async=False, manage_socket=True, get_result=True):
if manage_socket: try:
await self.open_websocket() if manage_socket:
await self.open_websocket()
res = await self._send_devtools_cmd({ res = await self._send_devtools_cmd({
"id": 1, "method": "Runtime.evaluate",
"method": "Runtime.evaluate", "params": {
"params": { "expression": js,
"expression": js, "userGesture": True,
"userGesture": True, "awaitPromise": run_async
"awaitPromise": run_async }
} }, get_result)
}, get_result)
if manage_socket: finally:
await self.client.close() if manage_socket:
await self.close_websocket()
return res 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): async def get_steam_resource(self, url):
res = await self.evaluate_js(f'(async function test() {{ return await (await fetch("{url}")).text() }})()', True) res = await self.evaluate_js(f'(async function test() {{ return await (await fetch("{url}")).text() }})()', True)
return res["result"]["result"]["value"] return res["result"]["result"]["value"]
@@ -59,59 +350,58 @@ class Tab:
def __repr__(self): def __repr__(self):
return self.title return self.title
async def get_tabs():
async with ClientSession() as web:
res = {}
while True: async def get_tabs() -> List[Tab]:
try: res = {}
res = await web.get(f"{BASE_ADDRESS}/json")
except ClientConnectorError: na = False
logger.debug("ClientConnectorError excepted.") 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("Steam isn't available yet. Wait for a moment...")
logger.error(format_exc()) na = True
await sleep(5) await sleep(5)
else: except ClientOSError:
break logger.warn(f"The request to {BASE_ADDRESS}/json was reset")
await sleep(1)
if res.status == 200: except TimeoutError:
r = await res.json() logger.warn(f"The request to {BASE_ADDRESS}/json timed out")
return [Tab(i) for i in r] await sleep(1)
else: else:
raise Exception(f"/json did not return 200. {await res.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() tabs = await get_tabs()
tab = next((i for i in tabs if i.title == tab_name), None) tab = next((i for i in tabs if i.title == tab_name), None)
if not tab: if not tab:
raise ValueError(f"Tab {tab_name} not found") raise ValueError(f"Tab {tab_name} not found")
return tab return tab
async def get_tab_lambda(test) -> Tab:
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): async def inject_to_tab(tab_name, js, run_async=False):
tab = await get_tab(tab_name) tab = await get_tab(tab_name)
return await tab.evaluate_js(js, run_async) return await tab.evaluate_js(js, run_async)
async def 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"]
+8 -6
View File
@@ -15,7 +15,7 @@ try:
except UnsupportedLibc: except UnsupportedLibc:
from watchdog.observers.fsevents import FSEventsObserver as Observer 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 from plugin import PluginWrapper
@@ -121,7 +121,7 @@ class Loader:
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') as bundle:
return web.Response(text=bundle.read(), content_type="application/javascript") return web.Response(text=bundle.read(), content_type="application/javascript")
def import_plugin(self, file, plugin_directory, refresh=False): def import_plugin(self, file, plugin_directory, refresh=False, batch=False):
try: try:
plugin = PluginWrapper(file, plugin_directory, self.plugin_path) plugin = PluginWrapper(file, plugin_directory, self.plugin_path)
if plugin.name in self.plugins: if plugin.name in self.plugins:
@@ -135,13 +135,15 @@ class Loader:
self.logger.info(f"Plugin {plugin.name} is passive") self.logger.info(f"Plugin {plugin.name} is passive")
self.plugins[plugin.name] = plugin.start() self.plugins[plugin.name] = plugin.start()
self.logger.info(f"Loaded {plugin.name}") self.logger.info(f"Loaded {plugin.name}")
self.loop.create_task(self.dispatch_plugin(plugin.name if not plugin.legacy else "$LEGACY_" + plugin.name, plugin.version)) 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: except Exception as e:
self.logger.error(f"Could not load {file}. {e}") self.logger.error(f"Could not load {file}. {e}")
print_exc() print_exc()
async def dispatch_plugin(self, name, version): async def dispatch_plugin(self, name, version):
await inject_to_tab("SP", f"window.importDeckyPlugin('{name}', '{version}')") gpui_tab = await get_gamepadui_tab()
await gpui_tab.evaluate_js(f"window.importDeckyPlugin('{name}', '{version}')")
def import_plugins(self): def import_plugins(self):
self.logger.info(f"import plugins from {self.plugin_path}") self.logger.info(f"import plugins from {self.plugin_path}")
@@ -149,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"))] directories = [i for i in listdir(self.plugin_path) if path.isdir(path.join(self.plugin_path, i)) and path.isfile(path.join(self.plugin_path, i, "plugin.json"))]
for directory in directories: for directory in directories:
self.logger.info(f"found plugin: {directory}") self.logger.info(f"found plugin: {directory}")
self.import_plugin(path.join(self.plugin_path, directory, "main.py"), directory) self.import_plugin(path.join(self.plugin_path, directory, "main.py"), directory, False, True)
async def handle_reloads(self): async def handle_reloads(self):
while True: while True:
@@ -206,7 +208,7 @@ class Loader:
return web.Response(text=ret) return web.Response(text=ret)
async def get_steam_resource(self, request): async def get_steam_resource(self, request):
tab = await get_tab("QuickAccess") tab = await get_tab("SP")
try: try:
return web.Response(text=await tab.get_steam_resource(f"https://steamloopback.host/{request.match_info['path']}"), content_type="text/html") return web.Response(text=await tab.get_steam_resource(f"https://steamloopback.host/{request.match_info['path']}"), content_type="text/html")
except Exception as e: except Exception as e:
+83 -33
View File
@@ -4,15 +4,15 @@ from subprocess import call
if hasattr(sys, '_MEIPASS'): if hasattr(sys, '_MEIPASS'):
call(['chmod', '-R', '755', sys._MEIPASS]) call(['chmod', '-R', '755', sys._MEIPASS])
# Full imports # Full imports
from asyncio import get_event_loop, sleep from asyncio import new_event_loop, set_event_loop, sleep
from json import dumps, loads from json import dumps, loads
from logging import DEBUG, INFO, basicConfig, getLogger from logging import DEBUG, INFO, basicConfig, getLogger
from os import getenv, chmod from os import getenv, chmod, path
from traceback import format_exc from traceback import format_exc
import aiohttp_cors import aiohttp_cors
# Partial imports # Partial imports
from aiohttp import ClientSession from aiohttp import client_exceptions, WSMsgType
from aiohttp.web import Application, Response, get, run_app, static from aiohttp.web import Application, Response, get, run_app, static
from aiohttp_jinja2 import setup as jinja_setup from aiohttp_jinja2 import setup as jinja_setup
@@ -21,8 +21,8 @@ from browser import PluginBrowser
from helpers import (REMOTE_DEBUGGER_UNIT, csrf_middleware, get_csrf_token, from helpers import (REMOTE_DEBUGGER_UNIT, csrf_middleware, get_csrf_token,
get_home_path, get_homebrew_path, get_user, get_home_path, get_homebrew_path, get_user,
get_user_group, set_user, set_user_group, get_user_group, set_user, set_user_group,
stop_systemd_unit) stop_systemd_unit, start_systemd_unit)
from injector import inject_to_tab, tab_has_global_var from injector import get_gamepadui_tab, Tab, get_tabs
from loader import Loader from loader import Loader
from settings import SettingsManager from settings import SettingsManager
from updater import Updater from updater import Updater
@@ -56,15 +56,15 @@ basicConfig(
logger = getLogger("Main") logger = getLogger("Main")
async def chown_plugin_dir(_): async def chown_plugin_dir():
code_chown = call(["chown", "-R", USER+":"+GROUP, CONFIG["plugin_path"]]) code_chown = call(["chown", "-R", USER+":"+GROUP, CONFIG["plugin_path"]])
code_chmod = call(["chmod", "-R", "555", CONFIG["plugin_path"]]) code_chmod = call(["chmod", "-R", "555", CONFIG["plugin_path"]])
if code_chown != 0 or code_chmod != 0: 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})") logger.error(f"chown/chmod exited with a non-zero exit code (chown: {code_chown}, chmod: {code_chmod})")
class PluginManager: class PluginManager:
def __init__(self) -> None: def __init__(self, loop) -> None:
self.loop = get_event_loop() self.loop = loop
self.web_app = Application() self.web_app = Application()
self.web_app.middlewares.append(csrf_middleware) self.web_app.middlewares.append(csrf_middleware)
self.cors = aiohttp_cors.setup(self.web_app, defaults={ self.cors = aiohttp_cors.setup(self.web_app, defaults={
@@ -81,12 +81,19 @@ class PluginManager:
self.updater = Updater(self) self.updater = Updater(self)
jinja_setup(self.web_app) jinja_setup(self.web_app)
if CONFIG["chown_plugin_path"] == True:
self.web_app.on_startup.append(chown_plugin_dir) async def startup(_):
self.loop.create_task(self.loader_reinjector()) if self.settings.getSetting("cef_forward", False):
self.loop.create_task(self.load_plugins()) self.loop.create_task(start_systemd_unit(REMOTE_DEBUGGER_UNIT))
if not self.settings.getSetting("cef_forward", False): else:
self.loop.create_task(stop_systemd_unit(REMOTE_DEBUGGER_UNIT)) 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.loop.set_exception_handler(self.exception_handler)
self.web_app.add_routes([get("/auth/token", self.get_auth_token)]) self.web_app.add_routes([get("/auth/token", self.get_auth_token)])
@@ -103,32 +110,73 @@ class PluginManager:
async def get_auth_token(self, request): async def get_auth_token(self, request):
return Response(text=get_csrf_token()) return Response(text=get_csrf_token())
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 load_plugins(self): async def load_plugins(self):
await self.wait_for_server() # await self.wait_for_server()
logger.debug("Loading plugins")
self.plugin_loader.import_plugins() self.plugin_loader.import_plugins()
# await inject_to_tab("SP", "window.syncDeckyPlugins();") # await inject_to_tab("SP", "window.syncDeckyPlugins();")
async def loader_reinjector(self): async def loader_reinjector(self):
await sleep(2)
await self.inject_javascript()
while True: while True:
await sleep(5) tab = None
if not await tab_has_global_var("SP", "deckyHasLoaded"): nf = False
logger.info("Plugin loader isn't present in Steam anymore, reinjecting...") dc = False
await self.inject_javascript() while not tab:
try:
tab = await get_gamepadui_tab()
except client_exceptions.ClientConnectorError or 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: try:
await inject_to_tab("SP", "try{if (window.deckyHasLoaded) location.reload();window.deckyHasLoaded = true;(async()=>{while(!window.SP_REACT){await new Promise(r => setTimeout(r, 10))};await import('http://localhost:1337/frontend/index.js')})();}catch(e){console.error(e)}", True) # if first:
# if await tab.has_global_var("deckyHasLoaded", False):
# tabs = await get_tabs()
# for t in tabs:
# if t.title != "Steam" and t.title != "SP":
# logger.debug("Closing tab: " + getattr(t, "title", "Untitled"))
# await t.close()
# await sleep(0.5)
await tab.evaluate_js("try{if (window.deckyHasLoaded){setTimeout(() => SteamClient.User.StartRestart(), 100)}else{window.deckyHasLoaded = true;(async()=>{try{while(!window.SP_REACT){await new Promise(r => setTimeout(r, 10))};await import('http://localhost:1337/frontend/index.js')}catch(e){console.error(e)};})();}}catch(e){console.error(e)}", False, False, False)
except: except:
logger.info("Failed to inject JavaScript into tab\n" + format_exc()) logger.info("Failed to inject JavaScript into tab\n" + format_exc())
pass pass
@@ -137,4 +185,6 @@ class PluginManager:
return run_app(self.web_app, host=CONFIG["server_host"], port=CONFIG["server_port"], loop=self.loop, access_log=None) return run_app(self.web_app, host=CONFIG["server_host"], port=CONFIG["server_port"], loop=self.loop, access_log=None)
if __name__ == "__main__": if __name__ == "__main__":
PluginManager().run() loop = new_event_loop()
set_event_loop(loop)
PluginManager(loop).run()
+10
View File
@@ -71,6 +71,15 @@ class PluginWrapper:
self.log.error("Failed to start " + self.name + "!\n" + format_exc()) self.log.error("Failed to start " + self.name + "!\n" + format_exc())
exit(0) exit(0)
async def _unload(self):
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): async def _setup_socket(self):
self.socket = await start_unix_server(self._listen_for_method_call, path=self.socket_addr, limit=BUFFER_LIMIT) self.socket = await start_unix_server(self._listen_for_method_call, path=self.socket_addr, limit=BUFFER_LIMIT)
@@ -90,6 +99,7 @@ class PluginWrapper:
break break
data = loads(line.decode("utf-8")) data = loads(line.decode("utf-8"))
if "stop" in data: if "stop" in data:
await self._unload()
get_event_loop().stop() get_event_loop().stop()
while get_event_loop().is_running(): while get_event_loop().is_running():
await sleep(0) await sleep(0)
+69 -28
View File
@@ -1,3 +1,5 @@
import os
import shutil
import uuid import uuid
from asyncio import sleep from asyncio import sleep
from ensurepip import version from ensurepip import version
@@ -9,7 +11,7 @@ from subprocess import call
from aiohttp import ClientSession, web from aiohttp import ClientSession, web
import helpers import helpers
from injector import get_tab, inject_to_tab from injector import get_gamepadui_tab, inject_to_tab
from settings import SettingsManager from settings import SettingsManager
logger = getLogger("Updater") logger = getLogger("Updater")
@@ -79,6 +81,20 @@ class Updater:
async def _get_branch(self, manager: SettingsManager): async def _get_branch(self, manager: SettingsManager):
return self.get_branch(manager) 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): async def get_version(self):
if self.localVer: if self.localVer:
return { return {
@@ -96,25 +112,20 @@ class Updater:
async with ClientSession() as web: async with ClientSession() as web:
async with web.request("GET", "https://api.github.com/repos/SteamDeckHomebrew/decky-loader/releases", ssl=helpers.get_ssl_context()) as res: async with web.request("GET", "https://api.github.com/repos/SteamDeckHomebrew/decky-loader/releases", ssl=helpers.get_ssl_context()) as res:
remoteVersions = await res.json() remoteVersions = await res.json()
self.allRemoteVers = remoteVersions self.allRemoteVers = remoteVersions
logger.debug("determining release type to find, branch is %i" % selectedBranch) logger.debug("determining release type to find, branch is %i" % selectedBranch)
if selectedBranch == 0: if selectedBranch == 0:
logger.debug("release type: release") logger.debug("release type: release")
self.remoteVer = next(filter(lambda ver: ver["tag_name"].startswith("v") and not ver["prerelease"] and ver["tag_name"], remoteVersions), None) self.remoteVer = next(filter(lambda ver: ver["tag_name"].startswith("v") and not ver["prerelease"] and ver["tag_name"], remoteVersions), None)
elif selectedBranch == 1: elif selectedBranch == 1:
logger.debug("release type: pre-release") logger.debug("release type: pre-release")
self.remoteVer = next(filter(lambda ver: ver["prerelease"] and ver["tag_name"].startswith("v") and ver["tag_name"].find("-pre"), remoteVersions), None) self.remoteVer = next(filter(lambda ver: ver["prerelease"] and ver["tag_name"].startswith("v") and ver["tag_name"].find("-pre"), remoteVersions), None)
# elif selectedBranch == 2: else:
# logger.debug("release type: nightly") logger.error("release type: NOT FOUND")
# self.remoteVer = next(filter(lambda ver: ver["prerelease"] and ver["tag_name"].startswith("v") and ver["tag_name"].find("nightly"), remoteVersions), None) raise ValueError("no valid branch found")
else: logger.info("Updated remote version information")
logger.error("release type: NOT FOUND") tab = await get_gamepadui_tab()
raise ValueError("no valid branch found") await tab.evaluate_js(f"window.DeckyPluginLoader.notifyUpdates()", False, True, False)
# doesn't make it to this line below or farther
# logger.debug("Remote Version: %s" % self.remoteVer.find("name"))
logger.info("Updated remote version information")
tab = await get_tab("SP")
await tab.evaluate_js(f"window.DeckyPluginLoader.notifyUpdates()", False, True, False)
return await self.get_version() return await self.get_version()
async def version_reloader(self): async def version_reloader(self):
@@ -127,14 +138,44 @@ class Updater:
await sleep(60 * 60 * 6) # 6 hours await sleep(60 * 60 * 6) # 6 hours
async def do_update(self): async def do_update(self):
logger.debug("Starting update.")
version = self.remoteVer["tag_name"] version = self.remoteVer["tag_name"]
download_url = self.remoteVer["assets"][0]["browser_download_url"] download_url = self.remoteVer["assets"][0]["browser_download_url"]
service_url = self.get_service_url()
logger.debug("Retrieved service URL")
tab = await get_tab("SP") tab = await get_gamepadui_tab()
await tab.open_websocket() await tab.open_websocket()
async with ClientSession() as web: async with ClientSession() as web:
logger.debug("Downloading systemd service")
# download the relevant systemd service depending upon branch
async with web.request("GET", service_url, ssl=helpers.get_ssl_context(), allow_redirects=True) as res:
logger.debug("Downloading service file")
data = await res.content.read()
logger.debug(str(data))
service_file_path = path.join(getcwd(), "plugin_loader.service")
try:
with open(path.join(getcwd(), "plugin_loader.service"), "wb") as out:
out.write(data)
except Exception as e:
logger.error(f"Error at %s", exc_info=e)
with open(path.join(getcwd(), "plugin_loader.service"), 'r') as service_file:
service_data = service_file.read()
service_data = service_data.replace("${HOMEBREW_FOLDER}", "/home/"+helpers.get_user()+"/homebrew")
with open(path.join(getcwd(), "plugin_loader.service"), 'w') as service_file:
service_file.write(service_data)
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: async with web.request("GET", download_url, ssl=helpers.get_ssl_context(), allow_redirects=True) as res:
total = int(res.headers.get('content-length', 0)) total = int(res.headers.get('content-length', 0))
# we need to not delete the binary until we have downloaded the new binary!
try: try:
remove(path.join(getcwd(), "PluginLoader")) remove(path.join(getcwd(), "PluginLoader"))
except: except:
@@ -150,14 +191,14 @@ class Updater:
self.context.loop.create_task(tab.evaluate_js(f"window.DeckyUpdater.updateProgress({new_progress})", False, False, False)) self.context.loop.create_task(tab.evaluate_js(f"window.DeckyUpdater.updateProgress({new_progress})", False, False, False))
progress = new_progress progress = new_progress
with open(path.join(getcwd(), ".loader.version"), "w") as out: with open(path.join(getcwd(), ".loader.version"), "w") as out:
out.write(version) out.write(version)
call(['chmod', '+x', path.join(getcwd(), "PluginLoader")]) call(['chmod', '+x', path.join(getcwd(), "PluginLoader")])
logger.info("Updated loader installation.")
logger.info("Updated loader installation.") await tab.evaluate_js("window.DeckyUpdater.finish()", False, False)
await tab.evaluate_js("window.DeckyUpdater.finish()", False, False) await self.do_restart()
await tab.client.close() await tab.close_websocket()
async def do_restart(self): async def do_restart(self):
call(["systemctl", "daemon-reload"]) call(["systemctl", "daemon-reload"])
+77 -8
View File
@@ -1,10 +1,13 @@
import uuid import uuid
import os import os
from json.decoder import JSONDecodeError 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 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 helpers
import subprocess import subprocess
@@ -26,9 +29,17 @@ class Utilities:
"disallow_remote_debugging": self.disallow_remote_debugging, "disallow_remote_debugging": self.disallow_remote_debugging,
"set_setting": self.set_setting, "set_setting": self.set_setting,
"get_setting": self.get_setting, "get_setting": self.get_setting,
"filepicker_ls": self.filepicker_ls "filepicker_ls": self.filepicker_ls,
"disable_rdt": self.disable_rdt,
"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: if context:
context.web_app.add_routes([ context.web_app.add_routes([
web.post("/methods/{method_name}", self._handle_server_method_call) web.post("/methods/{method_name}", self._handle_server_method_call)
@@ -69,12 +80,12 @@ class Utilities:
async def http_request(self, method="", url="", **kwargs): async def http_request(self, method="", url="", **kwargs):
async with ClientSession() as web: async with ClientSession() as web:
async with web.request(method, url, ssl=helpers.get_ssl_context(), **kwargs) as res: res = await web.request(method, url, ssl=helpers.get_ssl_context(), **kwargs)
return { return {
"status": res.status, "status": res.status,
"headers": dict(res.headers), "headers": dict(res.headers),
"body": await res.text() "body": await res.text()
} }
async def ping(self, **kwargs): async def ping(self, **kwargs):
return "pong" return "pong"
@@ -194,3 +205,61 @@ class Utilities:
"realpath": os.path.realpath(path), "realpath": os.path.realpath(path),
"files": files "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")
-50
View File
@@ -1,50 +0,0 @@
#!/bin/sh
[ "$UID" -eq 0 ] || exec sudo "$0" "$@"
echo "Installing Steam Deck Plugin Loader nightly..."
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 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 ${USER_DIR}/.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=${HOMEBREW_FOLDER}/services/PluginLoader
WorkingDirectory=${HOMEBREW_FOLDER}/services
Environment=PLUGIN_PATH=${HOMEBREW_FOLDER}/plugins
[Install]
WantedBy=multi-user.target
EOM
systemctl daemon-reload
systemctl start plugin_loader
systemctl enable plugin_loader
+24 -4
View File
@@ -7,8 +7,8 @@ echo "Installing Steam Deck Plugin Loader pre-release..."
USER_DIR="$(getent passwd $SUDO_USER | cut -d: -f6)" USER_DIR="$(getent passwd $SUDO_USER | cut -d: -f6)"
HOMEBREW_FOLDER="${USER_DIR}/homebrew" HOMEBREW_FOLDER="${USER_DIR}/homebrew"
# # Create folder structure # Create folder structure
rm -rf ${HOMEBREW_FOLDER}/services rm -rf "${HOMEBREW_FOLDER}/services"
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/services" sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/services"
sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/plugins" sudo -u $SUDO_USER mkdir -p "${HOMEBREW_FOLDER}/plugins"
@@ -26,10 +26,14 @@ systemctl --user disable plugin_loader 2> /dev/null
systemctl stop plugin_loader 2> /dev/null systemctl stop plugin_loader 2> /dev/null
systemctl disable 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-prerelease.service --output ${HOMEBREW_FOLDER}/services/plugin_loader-prerelease.service
cat > "${HOMEBREW_FOLDER}/services/plugin_loader-backup.service" <<- EOM
[Unit] [Unit]
Description=SteamDeck Plugin Loader Description=SteamDeck Plugin Loader
After=network-online.target
Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
User=root User=root
@@ -41,6 +45,22 @@ Environment=LOG_LEVEL=DEBUG
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
EOM 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 daemon-reload
systemctl start plugin_loader systemctl start plugin_loader
systemctl enable plugin_loader systemctl enable plugin_loader
+23 -2
View File
@@ -26,10 +26,14 @@ systemctl --user disable plugin_loader 2> /dev/null
systemctl stop plugin_loader 2> /dev/null systemctl stop plugin_loader 2> /dev/null
systemctl disable 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] [Unit]
Description=SteamDeck Plugin Loader Description=SteamDeck Plugin Loader
After=network-online.target
Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
User=root User=root
@@ -37,9 +41,26 @@ Restart=always
ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader ExecStart=${HOMEBREW_FOLDER}/services/PluginLoader
WorkingDirectory=${HOMEBREW_FOLDER}/services WorkingDirectory=${HOMEBREW_FOLDER}/services
Environment=PLUGIN_PATH=${HOMEBREW_FOLDER}/plugins Environment=PLUGIN_PATH=${HOMEBREW_FOLDER}/plugins
Environment=LOG_LEVEL=INFO
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
EOM 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 daemon-reload
systemctl start plugin_loader systemctl start plugin_loader
systemctl enable plugin_loader systemctl enable plugin_loader
+14
View File
@@ -0,0 +1,14 @@
[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
Environment=PLUGIN_PATH=${HOMEBREW_FOLDER}/plugins
Environment=LOG_LEVEL=DEBUG
[Install]
WantedBy=multi-user.target
+14
View File
@@ -0,0 +1,14 @@
[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
Environment=PLUGIN_PATH=${HOMEBREW_FOLDER}/plugins
Environment=LOG_LEVEL=INFO
[Install]
WantedBy=multi-user.target
+1 -1
View File
@@ -41,7 +41,7 @@
} }
}, },
"dependencies": { "dependencies": {
"decky-frontend-lib": "^3.1.3", "decky-frontend-lib": "^3.7.14",
"react-file-icon": "^1.2.0", "react-file-icon": "^1.2.0",
"react-icons": "^4.4.0", "react-icons": "^4.4.0",
"react-markdown": "^8.0.3", "react-markdown": "^8.0.3",
+4 -10
View File
@@ -10,7 +10,7 @@ specifiers:
'@types/react-file-icon': ^1.0.1 '@types/react-file-icon': ^1.0.1
'@types/react-router': 5.1.18 '@types/react-router': 5.1.18
'@types/webpack': ^5.28.0 '@types/webpack': ^5.28.0
decky-frontend-lib: ^3.1.3 decky-frontend-lib: ^3.7.14
husky: ^8.0.1 husky: ^8.0.1
import-sort-style-module: ^6.0.0 import-sort-style-module: ^6.0.0
inquirer: ^8.2.4 inquirer: ^8.2.4
@@ -30,7 +30,7 @@ specifiers:
typescript: ^4.7.4 typescript: ^4.7.4
dependencies: dependencies:
decky-frontend-lib: 3.1.3 decky-frontend-lib: 3.7.14
react-file-icon: 1.2.0_wcqkhtmu7mswc6yz4uyexck3ty react-file-icon: 1.2.0_wcqkhtmu7mswc6yz4uyexck3ty
react-icons: 4.4.0_react@16.14.0 react-icons: 4.4.0_react@16.14.0
react-markdown: 8.0.3_vshvapmxg47tngu7tvrsqpq55u react-markdown: 8.0.3_vshvapmxg47tngu7tvrsqpq55u
@@ -944,10 +944,8 @@ packages:
dependencies: dependencies:
ms: 2.1.2 ms: 2.1.2
/decky-frontend-lib/3.1.3: /decky-frontend-lib/3.7.14:
resolution: {integrity: sha512-X6DGi90VdXnyoQi8Q4jEYhvBiNQualMNwXWKwgFitdor2ktNj5xp3a4uIi1ijbnS/vdW2AGKjraTfOMXtHUNAg==} resolution: {integrity: sha512-ShAoP3VqiwkJYukDBHsOF9fk7wYw0VaKpHw6j9WdzLxwZwBcg0J7QBNIFYP3nfA0UgEwSJVEg/22kONiplipmA==}
dependencies:
minimist: 1.2.6
dev: false dev: false
/decode-named-character-reference/1.0.2: /decode-named-character-reference/1.0.2:
@@ -1944,10 +1942,6 @@ packages:
brace-expansion: 1.1.11 brace-expansion: 1.1.11
dev: true dev: true
/minimist/1.2.6:
resolution: {integrity: sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==}
dev: false
/mri/1.2.0: /mri/1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'} engines: {node: '>=4'}
+10 -1
View File
@@ -5,7 +5,12 @@ import externalGlobals from "rollup-plugin-external-globals";
import del from 'rollup-plugin-delete' import del from 'rollup-plugin-delete'
import replace from '@rollup/plugin-replace'; import replace from '@rollup/plugin-replace';
import typescript from '@rollup/plugin-typescript'; import typescript from '@rollup/plugin-typescript';
import { defineConfig } from 'rollup'; import { defineConfig, handleWarning } from 'rollup';
const hiddenWarnings = [
"THIS_IS_UNDEFINED",
"EVAL"
];
export default defineConfig({ export default defineConfig({
input: 'src/index.tsx', input: 'src/index.tsx',
@@ -35,5 +40,9 @@ export default defineConfig({
chunkFileNames: (chunkInfo) => { chunkFileNames: (chunkInfo) => {
return 'chunk-[hash].js' return 'chunk-[hash].js'
} }
},
onwarn: function ( message ) {
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(path: string, component: FC) {
this._components.set(path, component);
this.notifyUpdate();
}
removeComponent(path: string) {
this._components.delete(path);
this.notifyUpdate();
}
private notifyUpdate() {
this.eventBus.dispatchEvent(new Event('update'));
}
}
interface DeckyGlobalComponentsContext extends PublicDeckyGlobalComponentsState {
addComponent(path: string, component: FC): void;
removeComponent(path: 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>
);
};
+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>
);
};
+2 -2
View File
@@ -1,4 +1,4 @@
import { Focusable } from 'decky-frontend-lib'; import { Focusable, Router } from 'decky-frontend-lib';
import { FunctionComponent, useRef } from 'react'; import { FunctionComponent, useRef } from 'react';
import ReactMarkdown, { Options as ReactMarkdownOptions } from 'react-markdown'; import ReactMarkdown, { Options as ReactMarkdownOptions } from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
@@ -21,8 +21,8 @@ const Markdown: FunctionComponent<MarkdownProps> = (props) => {
<Focusable <Focusable
onActivate={() => {}} onActivate={() => {}}
onOKButton={() => { onOKButton={() => {
aRef?.current?.click();
props.onDismiss?.(); props.onDismiss?.();
Router.NavigateToExternalWeb(aRef.current!.href);
}} }}
style={{ display: 'inline' }} style={{ display: 'inline' }}
> >
+34 -24
View File
@@ -1,5 +1,6 @@
import { import {
ButtonItem, ButtonItem,
Focusable,
PanelSection, PanelSection,
PanelSectionRow, PanelSectionRow,
joinClassNames, joinClassNames,
@@ -10,38 +11,47 @@ import { VFC } from 'react';
import { useDeckyState } from './DeckyState'; import { useDeckyState } from './DeckyState';
import NotificationBadge from './NotificationBadge'; import NotificationBadge from './NotificationBadge';
import { useQuickAccessVisible } from './QuickAccessVisibleState';
import TitleView from './TitleView';
const PluginView: VFC = () => { const PluginView: VFC = () => {
const { plugins, updates, activePlugin, setActivePlugin } = useDeckyState(); const { plugins, updates, activePlugin, setActivePlugin, closeActivePlugin } = useDeckyState();
const visible = useQuickAccessVisible();
if (activePlugin) { if (activePlugin) {
return ( return (
<div <Focusable onCancelButton={closeActivePlugin}>
className={joinClassNames(staticClasses.TabGroupPanel, scrollClasses.ScrollPanel, scrollClasses.ScrollY)} <TitleView />
style={{ height: '100%' }} <div
> className={joinClassNames(staticClasses.TabGroupPanel, scrollClasses.ScrollPanel, scrollClasses.ScrollY)}
{activePlugin.content} style={{ height: '100%' }}
</div> >
{(visible || activePlugin.alwaysRender) && activePlugin.content}
</div>
</Focusable>
); );
} }
return ( return (
<div className={joinClassNames(staticClasses.TabGroupPanel, scrollClasses.ScrollPanel, scrollClasses.ScrollY)}> <>
<PanelSection> <TitleView />
{plugins <div className={joinClassNames(staticClasses.TabGroupPanel, scrollClasses.ScrollPanel, scrollClasses.ScrollY)}>
.filter((p) => p.content) <PanelSection>
.map(({ name, icon }) => ( {plugins
<PanelSectionRow key={name}> .filter((p) => p.content)
<ButtonItem layout="below" onClick={() => setActivePlugin(name)}> .map(({ name, icon }) => (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <PanelSectionRow key={name}>
{icon} <ButtonItem layout="below" onClick={() => setActivePlugin(name)}>
<div>{name}</div> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<NotificationBadge show={updates?.has(name)} style={{ top: '-5px', right: '-5px' }} /> {icon}
</div> <div>{name}</div>
</ButtonItem> <NotificationBadge show={updates?.has(name)} style={{ top: '-5px', right: '-5px' }} />
</PanelSectionRow> </div>
))} </ButtonItem>
</PanelSection> </PanelSectionRow>
</div> ))}
</PanelSection>
</div>
</>
); );
}; };
@@ -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>;
};
+11 -18
View File
@@ -2,13 +2,10 @@ import { ToastData, findModule, joinClassNames } from 'decky-frontend-lib';
import { FunctionComponent } from 'react'; import { FunctionComponent } from 'react';
interface ToastProps { interface ToastProps {
toast: { toast: ToastData;
data: ToastData;
nToastDurationMS: number;
};
} }
const toastClasses = findModule((mod) => { export const toastClasses = findModule((mod) => {
if (typeof mod !== 'object') return false; if (typeof mod !== 'object') return false;
if (mod.ToastPlaceholder) { if (mod.ToastPlaceholder) {
@@ -31,21 +28,17 @@ const templateClasses = findModule((mod) => {
const Toast: FunctionComponent<ToastProps> = ({ toast }) => { const Toast: FunctionComponent<ToastProps> = ({ toast }) => {
return ( return (
<div <div
style={{ '--toast-duration': `${toast.nToastDurationMS}ms` } as React.CSSProperties} style={{ '--toast-duration': `${toast.duration}ms` } as React.CSSProperties}
className={toastClasses.toastEnter} onClick={toast.onClick}
className={joinClassNames(templateClasses.ShortTemplate, toast.className || '')}
> >
<div {toast.logo && <div className={templateClasses.StandardLogoDimensions}>{toast.logo}</div>}
onClick={toast.data.onClick} <div className={joinClassNames(templateClasses.Content, toast.contentClassName || '')}>
className={joinClassNames(templateClasses.ShortTemplate, toast.data.className || '')} <div className={templateClasses.Header}>
> {toast.icon && <div className={templateClasses.Icon}>{toast.icon}</div>}
{toast.data.logo && <div className={templateClasses.StandardLogoDimensions}>{toast.data.logo}</div>} <div className={templateClasses.Title}>{toast.title}</div>
<div className={joinClassNames(templateClasses.Content, toast.data.contentClassName || '')}>
<div className={templateClasses.Header}>
{toast.data.icon && <div className={templateClasses.Icon}>{toast.data.icon}</div>}
<div className={templateClasses.Title}>{toast.data.title}</div>
</div>
<div className={templateClasses.Body}>{toast.data.body}</div>
</div> </div>
<div className={templateClasses.Body}>{toast.body}</div>
</div> </div>
</div> </div>
); );
+32 -18
View File
@@ -1,25 +1,39 @@
import { SidebarNavigation } from 'decky-frontend-lib'; 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 GeneralSettings from './pages/general';
import PluginList from './pages/plugin_list'; import PluginList from './pages/plugin_list';
const DeveloperSettings = lazy(() => import('./pages/developer'));
export default function SettingsPage() { export default function SettingsPage() {
return ( const [isDeveloper, setIsDeveloper] = useSetting<boolean>('developer.enabled', false);
<SidebarNavigation
title="Decky Settings" const pages = [
showTitle {
pages={[ title: 'General',
{ content: <GeneralSettings isDeveloper={isDeveloper} setIsDeveloper={setIsDeveloper} />,
title: 'General', route: '/decky/settings/general',
content: <GeneralSettings />, },
route: '/decky/settings/general', {
}, title: 'Plugins',
{ content: <PluginList />,
title: 'Plugins', route: '/decky/settings/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>
</>
);
}
@@ -10,7 +10,7 @@ const logger = new Logger('BranchSelect');
enum UpdateBranch { enum UpdateBranch {
Stable, Stable,
Prerelease, Prerelease,
// Nightly, // Testing,
} }
const BranchSelect: FunctionComponent<{}> = () => { const BranchSelect: FunctionComponent<{}> = () => {
@@ -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;
@@ -14,6 +14,7 @@ import { useEffect, useState } from 'react';
import { FaArrowDown } from 'react-icons/fa'; import { FaArrowDown } from 'react-icons/fa';
import { VerInfo, callUpdaterMethod, finishUpdate } from '../../../../updater'; import { VerInfo, callUpdaterMethod, finishUpdate } from '../../../../updater';
import { findSP } from '../../../../utils/windows';
import { useDeckyState } from '../../../DeckyState'; import { useDeckyState } from '../../../DeckyState';
import InlinePatchNotes from '../../../patchnotes/InlinePatchNotes'; import InlinePatchNotes from '../../../patchnotes/InlinePatchNotes';
import WithSuspense from '../../../WithSuspense'; import WithSuspense from '../../../WithSuspense';
@@ -21,6 +22,7 @@ import WithSuspense from '../../../WithSuspense';
const MarkdownRenderer = lazy(() => import('../../../Markdown')); const MarkdownRenderer = lazy(() => import('../../../Markdown'));
function PatchNotesModal({ versionInfo, closeModal }: { versionInfo: VerInfo | null; closeModal?: () => {} }) { function PatchNotesModal({ versionInfo, closeModal }: { versionInfo: VerInfo | null; closeModal?: () => {} }) {
const SP = findSP();
return ( return (
<Focusable onCancelButton={closeModal}> <Focusable onCancelButton={closeModal}>
<FocusRing> <FocusRing>
@@ -50,12 +52,13 @@ function PatchNotesModal({ versionInfo, closeModal }: { versionInfo: VerInfo | n
)} )}
fnGetId={(id) => id} fnGetId={(id) => id}
nNumItems={versionInfo?.all?.length} nNumItems={versionInfo?.all?.length}
nHeight={window.innerHeight - 40} nHeight={SP.innerHeight - 40}
nItemHeight={window.innerHeight - 40} nItemHeight={SP.innerHeight - 40}
nItemMarginX={0} nItemMarginX={0}
initialColumn={0} initialColumn={0}
autoFocus={true} autoFocus={true}
fnGetColumnWidth={() => window.innerWidth} fnGetColumnWidth={() => SP.innerWidth}
name="Decky Updates"
/> />
</FocusRing> </FocusRing>
</Focusable> </Focusable>
@@ -1,29 +1,40 @@
import { DialogButton, Field, TextField } from 'decky-frontend-lib'; import { DialogButton, Field, TextField, Toggle } from 'decky-frontend-lib';
import { useState } from 'react'; import { useState } from 'react';
import { FaShapes } from 'react-icons/fa'; import { FaShapes, FaTools } from 'react-icons/fa';
import { installFromURL } from '../../../../store'; import { installFromURL } from '../../../../store';
import BranchSelect from './BranchSelect'; import BranchSelect from './BranchSelect';
import RemoteDebuggingSettings from './RemoteDebugging'; import RemoteDebuggingSettings from './RemoteDebugging';
import StoreSelect from './StoreSelect';
import UpdaterSettings from './Updater'; import UpdaterSettings from './Updater';
export default function GeneralSettings() { export default function GeneralSettings({
isDeveloper,
setIsDeveloper,
}: {
isDeveloper: boolean;
setIsDeveloper: (val: boolean) => void;
}) {
const [pluginURL, setPluginURL] = useState(''); const [pluginURL, setPluginURL] = useState('');
// const [checked, setChecked] = useState(false); // store these in some kind of State instead
return ( return (
<div> <div>
{/* <Field
label="A Toggle with an icon"
icon={<FaShapes style={{ display: 'block' }} />}
>
<Toggle
value={checked}
onChange={(e) => setChecked(e)}
/>
</Field> */}
<UpdaterSettings /> <UpdaterSettings />
<BranchSelect /> <BranchSelect />
<StoreSelect />
<RemoteDebuggingSettings /> <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 <Field
label="Manual plugin install" label="Manual plugin install"
description={<TextField label={'URL'} value={pluginURL} onChange={(e) => setPluginURL(e?.target.value)} />} description={<TextField label={'URL'} value={pluginURL} onChange={(e) => setPluginURL(e?.target.value)} />}
+11 -59
View File
@@ -11,17 +11,10 @@ import {
} from 'decky-frontend-lib'; } from 'decky-frontend-lib';
import { FC, useRef, useState } from 'react'; import { FC, useRef, useState } from 'react';
import { import { StorePlugin, StorePluginVersion, requestPluginInstall } from '../../store';
LegacyStorePlugin,
StorePlugin,
StorePluginVersion,
isLegacyPlugin,
requestLegacyPluginInstall,
requestPluginInstall,
} from '../../store';
interface PluginCardProps { interface PluginCardProps {
plugin: StorePlugin | LegacyStorePlugin; plugin: StorePlugin;
} }
const PluginCard: FC<PluginCardProps> = ({ plugin }) => { const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
@@ -63,22 +56,13 @@ const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
}} }}
> >
<div className="deckyStoreCardHeader" style={{ display: 'flex', alignItems: 'center' }}> <div className="deckyStoreCardHeader" style={{ display: 'flex', alignItems: 'center' }}>
<a <div
style={{ fontSize: '18pt', padding: '10px' }} style={{ fontSize: '18pt', padding: '10px' }}
className={joinClassNames(staticClasses.Text)} className={joinClassNames(staticClasses.Text)}
// onClick={() => Router.NavigateToExternalWeb('https://github.com/' + plugin.artifact)} // onClick={() => Router.NavigateToExternalWeb('https://github.com/' + plugin.artifact)}
> >
{isLegacyPlugin(plugin) ? ( {plugin.name}
<div className="deckyStoreCardNameContainer"> </div>
<span className="deckyStoreCardLegacyRepoOwner" style={{ color: 'grey' }}>
{plugin.artifact.split('/')[0]}/
</span>
{plugin.artifact.split('/')[1]}
</div>
) : (
plugin.name
)}
</a>
</div> </div>
<div <div
style={{ style={{
@@ -94,17 +78,7 @@ const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
width: 'auto', width: 'auto',
height: '160px', height: '160px',
}} }}
src={ src={plugin.image_url}
isLegacyPlugin(plugin)
? `https://cdn.tzatzikiweeb.moe/file/steam-deck-homebrew/artifact_images/${plugin.artifact.replace(
'/',
'_',
)}.png`
: `https://cdn.tzatzikiweeb.moe/file/steam-deck-homebrew/artifact_images/${plugin.name.replace(
'/',
'_',
)}.png`
}
/> />
<div <div
style={{ style={{
@@ -152,19 +126,6 @@ const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
{tag == 'root' ? 'Requires root' : tag} {tag == 'root' ? 'Requires root' : tag}
</span> </span>
))} ))}
{isLegacyPlugin(plugin) && (
<span
className="deckyStoreCardTag deckyStoreCardLegacyTag"
style={{
color: '#232120',
padding: '5px',
borderRadius: '5px',
background: '#EDE841',
}}
>
legacy
</span>
)}
</p> </p>
</div> </div>
</div> </div>
@@ -194,11 +155,7 @@ const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
<DialogButton <DialogButton
className="deckyStoreCardInstallButton" className="deckyStoreCardInstallButton"
ref={buttonRef} ref={buttonRef}
onClick={() => onClick={() => requestPluginInstall(plugin.name, plugin.versions[selectedOption])}
isLegacyPlugin(plugin)
? requestLegacyPluginInstall(plugin, Object.keys(plugin.versions)[selectedOption])
: requestPluginInstall(plugin.name, plugin.versions[selectedOption])
}
> >
Install Install
</DialogButton> </DialogButton>
@@ -211,15 +168,10 @@ const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
> >
<Dropdown <Dropdown
rgOptions={ rgOptions={
(isLegacyPlugin(plugin) plugin.versions.map((version: StorePluginVersion, index) => ({
? Object.keys(plugin.versions).map((v, k) => ({ data: index,
data: k, label: version.name,
label: v, })) as SingleDropdownOption[]
}))
: plugin.versions.map((version: StorePluginVersion, index) => ({
data: index,
label: version.name,
}))) as SingleDropdownOption[]
} }
strDefaultLabel={'Select a version'} strDefaultLabel={'Select a version'}
selectedOption={selectedOption} selectedOption={selectedOption}
+1 -12
View File
@@ -2,14 +2,13 @@ import { SteamSpinner } from 'decky-frontend-lib';
import { FC, useEffect, useState } from 'react'; import { FC, useEffect, useState } from 'react';
import Logger from '../../logger'; import Logger from '../../logger';
import { LegacyStorePlugin, StorePlugin, getLegacyPluginList, getPluginList } from '../../store'; import { StorePlugin, getPluginList } from '../../store';
import PluginCard from './PluginCard'; import PluginCard from './PluginCard';
const logger = new Logger('FilePicker'); const logger = new Logger('FilePicker');
const StorePage: FC<{}> = () => { const StorePage: FC<{}> = () => {
const [data, setData] = useState<StorePlugin[] | null>(null); const [data, setData] = useState<StorePlugin[] | null>(null);
const [legacyData, setLegacyData] = useState<LegacyStorePlugin[] | null>(null);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@@ -17,11 +16,6 @@ const StorePage: FC<{}> = () => {
logger.log('got data!', res); logger.log('got data!', res);
setData(res); setData(res);
})(); })();
(async () => {
const res = await getLegacyPluginList();
logger.log('got legacy data!', res);
setLegacyData(res);
})();
}, []); }, []);
return ( return (
@@ -49,11 +43,6 @@ const StorePage: FC<{}> = () => {
{data.map((plugin: StorePlugin) => ( {data.map((plugin: StorePlugin) => (
<PluginCard plugin={plugin} /> <PluginCard plugin={plugin} />
))} ))}
{!legacyData ? (
<SteamSpinner />
) : (
legacyData.map((plugin: LegacyStorePlugin) => <PluginCard plugin={plugin} />)
)}
</div> </div>
)} )}
</div> </div>
+96
View File
@@ -0,0 +1,96 @@
import {
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 function setShowValveInternal(show: boolean) {
const settingsMod = findModuleChild((m) => {
if (typeof m !== 'object') return undefined;
for (let prop in m) {
if (typeof m[prop]?.settings?.bIsValveEmail !== 'undefined') return m[prop];
}
});
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,
ReactUtils: {
fakeRenderComponent,
findInReactTree,
findInTree,
},
Router,
ReactRouter,
classes: {
scrollClasses,
staticClasses,
playSectionClasses,
scrollPanelClasses,
updaterFieldClasses,
gamepadDialogClasses,
gamepadSliderClasses,
quickAccessMenuClasses,
quickAccessControlsClasses,
},
};
}
+3 -25
View File
@@ -1,6 +1,3 @@
import { ButtonItem, CommonUIModule, webpackCache } from 'decky-frontend-lib';
import { forwardRef } from 'react';
import PluginLoader from './plugin-loader'; import PluginLoader from './plugin-loader';
import { DeckyUpdater } from './updater'; import { DeckyUpdater } from './updater';
@@ -11,32 +8,12 @@ declare global {
importDeckyPlugin: Function; importDeckyPlugin: Function;
syncDeckyPlugins: Function; syncDeckyPlugins: Function;
deckyHasLoaded: boolean; deckyHasLoaded: boolean;
deckyHasConnectedRDT?: boolean;
deckyAuthToken: string; deckyAuthToken: string;
webpackJsonp: any; DFL?: any;
} }
} }
// HACK to fix plugins using webpack v4 push
const v4Cache = {};
for (let m of Object.keys(webpackCache)) {
v4Cache[m] = { exports: webpackCache[m] };
}
if (!window.webpackJsonp || window.webpackJsonp.deckyShimmed) {
window.webpackJsonp = {
deckyShimmed: true,
push: (mod: any): any => {
if (mod[1].get_require) return { c: v4Cache };
},
};
CommonUIModule.__deckyButtonItemShim = forwardRef((props: any, ref: any) => {
// tricks the old filter into working
const dummy = `childrenContainerWidth:"min"`;
return <ButtonItem ref={ref} _shim={dummy} {...props} />;
});
}
(async () => { (async () => {
window.deckyAuthToken = await fetch('http://127.0.0.1:1337/auth/token').then((r) => r.text()); window.deckyAuthToken = await fetch('http://127.0.0.1:1337/auth/token').then((r) => r.text());
@@ -44,6 +21,7 @@ if (!window.webpackJsonp || window.webpackJsonp.deckyShimmed) {
window.DeckyPluginLoader?.deinit(); window.DeckyPluginLoader?.deinit();
window.DeckyPluginLoader = new PluginLoader(); window.DeckyPluginLoader = new PluginLoader();
window.DeckyPluginLoader.init();
window.importDeckyPlugin = function (name: string, version: string) { window.importDeckyPlugin = function (name: string, version: string) {
window.DeckyPluginLoader?.importPlugin(name, version); window.DeckyPluginLoader?.importPlugin(name, version);
}; };
+43 -48
View File
@@ -4,15 +4,12 @@ import {
Patch, Patch,
QuickAccessTab, QuickAccessTab,
Router, Router,
callOriginal,
findModuleChild,
replacePatch,
showModal, showModal,
sleep, sleep,
staticClasses, staticClasses,
} from 'decky-frontend-lib'; } from 'decky-frontend-lib';
import { lazy } from 'react'; import { FC, lazy } from 'react';
import { FaPlug } from 'react-icons/fa'; import { FaCog, FaExclamationCircle, FaPlug } from 'react-icons/fa';
import { DeckyState, DeckyStateContextProvider, useDeckyState } from './components/DeckyState'; import { DeckyState, DeckyStateContextProvider, useDeckyState } from './components/DeckyState';
import LegacyPlugin from './components/LegacyPlugin'; import LegacyPlugin from './components/LegacyPlugin';
@@ -20,15 +17,16 @@ import { deinitFilepickerPatches, initFilepickerPatches } from './components/mod
import PluginInstallModal from './components/modals/PluginInstallModal'; import PluginInstallModal from './components/modals/PluginInstallModal';
import NotificationBadge from './components/NotificationBadge'; import NotificationBadge from './components/NotificationBadge';
import PluginView from './components/PluginView'; import PluginView from './components/PluginView';
import TitleView from './components/TitleView';
import WithSuspense from './components/WithSuspense'; import WithSuspense from './components/WithSuspense';
import Logger from './logger'; import Logger from './logger';
import { Plugin } from './plugin'; import { Plugin } from './plugin';
import RouterHook from './router-hook'; import RouterHook from './router-hook';
import { checkForUpdates } from './store'; import { checkForUpdates } from './store';
import TabsHook from './tabs-hook'; import TabsHook from './tabs-hook';
import OldTabsHook from './tabs-hook.old';
import Toaster from './toaster'; import Toaster from './toaster';
import { VerInfo, callUpdaterMethod } from './updater'; import { VerInfo, callUpdaterMethod } from './updater';
import { getSetting } from './utils/settings';
const StorePage = lazy(() => import('./components/store/Store')); const StorePage = lazy(() => import('./components/store/Store'));
const SettingsPage = lazy(() => import('./components/settings')); const SettingsPage = lazy(() => import('./components/settings'));
@@ -41,10 +39,10 @@ declare global {
class PluginLoader extends Logger { class PluginLoader extends Logger {
private plugins: Plugin[] = []; private plugins: Plugin[] = [];
private tabsHook: TabsHook = new TabsHook(); private tabsHook: TabsHook | OldTabsHook = document.title == 'SP' ? new OldTabsHook() : new TabsHook();
// private windowHook: WindowHook = new WindowHook(); // private windowHook: WindowHook = new WindowHook();
private routerHook: RouterHook = new RouterHook(); private routerHook: RouterHook = new RouterHook();
private toaster: Toaster = new Toaster(); public toaster: Toaster = new Toaster();
private deckyState: DeckyState = new DeckyState(); private deckyState: DeckyState = new DeckyState();
private reloadLock: boolean = false; private reloadLock: boolean = false;
@@ -55,6 +53,7 @@ class PluginLoader extends Logger {
constructor() { constructor() {
super(PluginLoader.name); super(PluginLoader.name);
this.tabsHook.init();
this.log('Initialized'); this.log('Initialized');
const TabBadge = () => { const TabBadge = () => {
@@ -67,7 +66,6 @@ class PluginLoader extends Logger {
title: null, title: null,
content: ( content: (
<DeckyStateContextProvider deckyState={this.deckyState}> <DeckyStateContextProvider deckyState={this.deckyState}>
<TitleView />
<PluginView /> <PluginView />
</DeckyStateContextProvider> </DeckyStateContextProvider>
), ),
@@ -97,38 +95,6 @@ class PluginLoader extends Logger {
initFilepickerPatches(); initFilepickerPatches();
this.updateVersion(); this.updateVersion();
const self = this;
try {
// TODO remove all of this once Valve fixes the bug
const focusManager = findModuleChild((m) => {
if (typeof m !== 'object') return false;
for (let prop in m) {
if (m[prop]?.prototype?.TakeFocus) return m[prop];
}
return false;
});
this.focusWorkaroundPatch = replacePatch(focusManager.prototype, 'TakeFocus', function () {
// @ts-ignore
const classList = this.m_node?.m_element.classList;
if (
// @ts-ignore
(this.m_node?.m_element && classList.contains(staticClasses.TabGroupPanel)) ||
classList.contains('FriendsListTab') ||
classList.contains('FriendsTabList') ||
classList.contains('FriendsListAndChatsSteamDeck')
) {
self.debug('Intercepted friends re-focus');
return true;
}
return callOriginal;
});
} catch (e) {
this.error('Friends focus patch failed', e);
}
} }
public async updateVersion() { public async updateVersion() {
@@ -209,6 +175,12 @@ class PluginLoader extends Logger {
} }
} }
public init() {
getSetting('developer.enabled', false).then((val) => {
if (val) import('./developer').then((developer) => developer.startup());
});
}
public deinit() { public deinit() {
this.routerHook.removeRoute('/decky/store'); this.routerHook.removeRoute('/decky/store');
this.routerHook.removeRoute('/decky/settings'); this.routerHook.removeRoute('/decky/settings');
@@ -263,13 +235,36 @@ class PluginLoader extends Logger {
}, },
}); });
if (res.ok) { if (res.ok) {
let plugin_export = await eval(await res.text()); try {
let plugin = plugin_export(this.createPluginAPI(name)); let plugin_export = await eval(await res.text());
this.plugins.push({ let plugin = plugin_export(this.createPluginAPI(name));
...plugin, this.plugins.push({
name: name, ...plugin,
version: version, 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`); } else throw new Error(`${name} frontend_bundle not OK`);
} }
+1
View File
@@ -4,4 +4,5 @@ export interface Plugin {
icon: JSX.Element; icon: JSX.Element;
content?: JSX.Element; content?: JSX.Element;
onDismount?(): void; onDismount?(): void;
alwaysRender?: boolean;
} }
+67 -22
View File
@@ -1,7 +1,12 @@
import { Patch, afterPatch, findModuleChild } from 'decky-frontend-lib'; import { Patch, afterPatch, findModuleChild } from 'decky-frontend-lib';
import { ReactElement, ReactNode, cloneElement, createElement, memo } from 'react'; import { FC, ReactElement, ReactNode, cloneElement, createElement, memo } from 'react';
import type { Route } from 'react-router'; import type { Route } from 'react-router';
import {
DeckyGlobalComponentsState,
DeckyGlobalComponentsStateContextProvider,
useDeckyGlobalComponentsState,
} from './components/DeckyGlobalComponentsState';
import { import {
DeckyRouterState, DeckyRouterState,
DeckyRouterStateContextProvider, DeckyRouterStateContextProvider,
@@ -22,8 +27,10 @@ class RouterHook extends Logger {
private memoizedRouter: any; private memoizedRouter: any;
private gamepadWrapper: any; private gamepadWrapper: any;
private routerState: DeckyRouterState = new DeckyRouterState(); private routerState: DeckyRouterState = new DeckyRouterState();
private globalComponentsState: DeckyGlobalComponentsState = new DeckyGlobalComponentsState();
private wrapperPatch: Patch; private wrapperPatch: Patch;
private routerPatch?: Patch; private routerPatch?: Patch;
public routes?: any[];
constructor() { constructor() {
super('RouterHook'); super('RouterHook');
@@ -42,24 +49,28 @@ class RouterHook extends Logger {
let Route: new () => Route; let Route: new () => Route;
// Used to store the new replicated routes we create to allow routes to be unpatched. // Used to store the new replicated routes we create to allow routes to be unpatched.
let toReplace = new Map<string, ReactNode>(); const processList = (
const DeckyWrapper = ({ children }: { children: ReactElement }) => { routeList: any[],
const { routes, routePatches } = useDeckyRouterState(); routes: Map<string, RouterEntry> | null,
routePatches: Map<string, Set<RoutePatch>>,
const routeList = children.props.children[0].props.children; save: boolean,
) => {
this.debug('Route list: ', routeList);
if (save) this.routes = routeList;
let routerIndex = routeList.length; let routerIndex = routeList.length;
if (!routeList[routerIndex - 1]?.length || routeList[routerIndex - 1]?.length !== routes.size) { if (routes) {
if (routeList[routerIndex - 1]?.length && routeList[routerIndex - 1].length !== routes.size) routerIndex--; if (!routeList[routerIndex - 1]?.length || routeList[routerIndex - 1]?.length !== routes.size) {
const newRouterArray: ReactElement[] = []; if (routeList[routerIndex - 1]?.length && routeList[routerIndex - 1].length !== routes.size) routerIndex--;
routes.forEach(({ component, props }, path) => { const newRouterArray: ReactElement[] = [];
newRouterArray.push( routes.forEach(({ component, props }, path) => {
<Route path={path} {...props}> newRouterArray.push(
{createElement(component)} <Route path={path} {...props}>
</Route>, {createElement(component)}
); </Route>,
}); );
routeList[routerIndex] = newRouterArray; });
routeList[routerIndex] = newRouterArray;
}
} }
routeList.forEach((route: Route, index: number) => { routeList.forEach((route: Route, index: number) => {
const replaced = toReplace.get(route?.props?.path as string); const replaced = toReplace.get(route?.props?.path as string);
@@ -85,19 +96,40 @@ class RouterHook extends Logger {
}); });
} }
}); });
};
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'); this.debug('Rerendered routes list');
return children; return children;
}; };
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}</>;
};
this.wrapperPatch = afterPatch(this.gamepadWrapper, 'render', (_: any, ret: any) => { this.wrapperPatch = afterPatch(this.gamepadWrapper, 'render', (_: any, ret: any) => {
if (ret?.props?.children?.props?.children?.length == 5) { 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 ( if (
ret.props.children.props.children[2]?.props?.children?.[0]?.type?.type ret.props.children.props.children[idx]?.props?.children?.[0]?.type?.type
?.toString() ?.toString()
?.includes('GamepadUI.Settings.Root()') ?.includes('GamepadUI.Settings.Root()')
) { ) {
if (!this.router) { if (!this.router) {
this.router = ret.props.children.props.children[2]?.props?.children?.[0]?.type; this.router = ret.props.children.props.children[idx]?.props?.children?.[0]?.type;
this.routerPatch = afterPatch(this.router, 'type', (_: any, ret: any) => { this.routerPatch = afterPatch(this.router, 'type', (_: any, ret: any) => {
if (!Route) if (!Route)
Route = ret.props.children[0].props.children.find((x: any) => x.props.path == '/createaccount').type; Route = ret.props.children[0].props.children.find((x: any) => x.props.path == '/createaccount').type;
@@ -111,7 +143,12 @@ class RouterHook extends Logger {
this.memoizedRouter = memo(this.router.type); this.memoizedRouter = memo(this.router.type);
this.memoizedRouter.isDeckyRouter = true; this.memoizedRouter.isDeckyRouter = true;
} }
ret.props.children.props.children[2].props.children[0].type = this.memoizedRouter; ret.props.children.props.children.push(
<DeckyGlobalComponentsStateContextProvider deckyGlobalComponentsState={this.globalComponentsState}>
<DeckyGlobalComponentsWrapper />
</DeckyGlobalComponentsStateContextProvider>,
);
ret.props.children.props.children[idx].props.children[0].type = this.memoizedRouter;
} }
} }
return ret; return ret;
@@ -126,6 +163,14 @@ class RouterHook extends Logger {
return this.routerState.addPatch(path, patch); 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) { removePatch(path: string, patch: RoutePatch) {
this.routerState.removePatch(path, patch); this.routerState.removePatch(path, patch);
} }
+45 -51
View File
@@ -1,6 +1,11 @@
import { ConfirmModal, showModal, staticClasses } from 'decky-frontend-lib';
import { Plugin } from './plugin'; import { Plugin } from './plugin';
import { getSetting, setSetting } from './utils/settings';
export enum Store {
Default,
Testing,
Custom,
}
export interface StorePluginVersion { export interface StorePluginVersion {
name: string; name: string;
@@ -14,31 +19,49 @@ export interface StorePlugin {
author: string; author: string;
description: string; description: string;
tags: string[]; tags: string[];
} image_url: string;
export interface LegacyStorePlugin {
artifact: string;
versions: {
[version: string]: string;
};
author: string;
description: string;
tags: string[];
} }
// name: version // name: version
export type PluginUpdateMapping = Map<string, StorePluginVersion>; export type PluginUpdateMapping = Map<string, StorePluginVersion>;
export function getPluginList(): Promise<StorePlugin[]> { export async function getPluginList(): Promise<StorePlugin[]> {
return fetch('https://beta.deckbrew.xyz/plugins', { let version = await window.DeckyPluginLoader.updateVersion();
method: 'GET', let store = await getSetting<Store>('store', Store.Default);
}).then((r) => r.json()); let customURL = await getSetting<string>('store-url', 'https://plugins.deckbrew.xyz/plugins');
} let storeURL;
if (!store) {
export function getLegacyPluginList(): Promise<LegacyStorePlugin[]> { console.log('Could not get a default store, using Default.');
return fetch('https://plugins.deckbrew.xyz/get_plugins', { await setSetting('store-url', Store.Default);
method: 'GET', return fetch('https://plugins.deckbrew.xyz/plugins', {
}).then((r) => r.json()); 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) { export async function installFromURL(url: string) {
@@ -49,31 +72,6 @@ export async function installFromURL(url: string) {
}); });
} }
export function requestLegacyPluginInstall(plugin: LegacyStorePlugin, selectedVer: string) {
showModal(
<ConfirmModal
onOK={() => {
window.DeckyPluginLoader.callServerMethod('install_plugin', {
name: plugin.artifact,
artifact: `https://github.com/${plugin.artifact}/archive/refs/tags/${selectedVer}.zip`,
version: selectedVer,
hash: plugin.versions[selectedVer],
});
}}
onCancel={() => {
// do nothing
}}
>
<div className={staticClasses.Title} style={{ flexDirection: 'column', boxShadow: 'unset' }}>
Using legacy plugins
</div>
You are currently installing a <b>legacy</b> plugin. Legacy plugins are no longer supported and may have issues.
Legacy plugins do not support gamepad input. To interact with a legacy plugin, you will need to use the
touchscreen.
</ConfirmModal>,
);
}
export async function requestPluginInstall(plugin: string, selectedVer: StorePluginVersion) { export async function requestPluginInstall(plugin: string, selectedVer: StorePluginVersion) {
await window.DeckyPluginLoader.callServerMethod('install_plugin', { await window.DeckyPluginLoader.callServerMethod('install_plugin', {
name: plugin, name: plugin,
@@ -94,7 +92,3 @@ export async function checkForUpdates(plugins: Plugin[]): Promise<PluginUpdateMa
} }
return updateMap; return updateMap;
} }
export function isLegacyPlugin(plugin: LegacyStorePlugin | StorePlugin): plugin is LegacyStorePlugin {
return 'artifact' in plugin;
}
@@ -1,12 +1,10 @@
import { Patch, QuickAccessTab, afterPatch, sleep } from 'decky-frontend-lib'; // TabsHook for versions before the Desktop merge
import { Patch, afterPatch, sleep } from 'decky-frontend-lib';
import { memo } from 'react'; import { memo } from 'react';
import Logger from './logger'; import NewTabsHook from './tabs-hook';
declare global { declare global {
interface Window {
__TABS_HOOK_INSTANCE: any;
}
interface Array<T> { interface Array<T> {
__filter: any; __filter: any;
} }
@@ -17,16 +15,8 @@ const isTabsArray = (tabs: any) => {
return length >= 7 && tabs[length - 1]?.tab; return length >= 7 && tabs[length - 1]?.tab;
}; };
interface Tab { class TabsHook extends NewTabsHook {
id: QuickAccessTab | number;
title: any;
content: any;
icon: any;
}
class TabsHook extends Logger {
// private keys = 7; // private keys = 7;
tabs: Tab[] = [];
private quickAccess: any; private quickAccess: any;
private tabRenderer: any; private tabRenderer: any;
private memoizedQuickAccess: any; private memoizedQuickAccess: any;
@@ -38,12 +28,12 @@ class TabsHook extends Logger {
private cNodePatch?: Patch; private cNodePatch?: Patch;
constructor() { constructor() {
super('TabsHook'); super();
this.log('Initialized'); this.log('Initialized stable TabsHook');
window.__TABS_HOOK_INSTANCE?.deinit?.(); }
window.__TABS_HOOK_INSTANCE = this;
init() {
const self = this; const self = this;
const tree = (document.getElementById('root') as any)._reactRootContainer._internalRoot.current; const tree = (document.getElementById('root') as any)._reactRootContainer._internalRoot.current;
let scrollRoot: any; let scrollRoot: any;
@@ -83,17 +73,17 @@ class TabsHook extends Logger {
if (ret) { if (ret) {
if (!newQATabRenderer) { if (!newQATabRenderer) {
this.tabRenderer = ret.props.children[1].children.type; this.tabRenderer = ret.props.children[1].children.type;
newQATabRenderer = (...args: any) => { newQATabRenderer = (...qamArgs: any[]) => {
const oFilter = Array.prototype.filter; const oFilter = Array.prototype.filter;
Array.prototype.filter = function (...args: any[]) { Array.prototype.filter = function (...args: any[]) {
if (isTabsArray(this)) { if (isTabsArray(this)) {
self.render(this); self.render(this, qamArgs[0].visible);
} }
// @ts-ignore // @ts-ignore
return oFilter.call(this, ...args); 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 // TODO remove array hack entirely and use this instead const tabs = ret.props.children.props.children[0].props.children[1].props.children[0].props.children[0].props.tabs
const ret = this.tabRenderer(...args); const ret = this.tabRenderer(...qamArgs);
Array.prototype.filter = oFilter; Array.prototype.filter = oFilter;
return ret; return ret;
}; };
@@ -124,27 +114,6 @@ class TabsHook extends Logger {
if (this.rendererTree) this.rendererTree.type = this.tabRenderer; if (this.rendererTree) this.rendererTree.type = this.tabRenderer;
if (this.cNode) this.cNode.stateNode.forceUpdate(); if (this.cNode) this.cNode.stateNode.forceUpdate();
} }
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[]) {
for (const { title, icon, content, id } of this.tabs) {
existingTabs.push({
key: id,
title,
tab: icon,
panel: content,
});
}
}
} }
export default TabsHook; export default TabsHook;
+154
View File
@@ -0,0 +1,154 @@
// 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;
securitystore: 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;
private unsubscribeSecurity?: () => void;
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;
this.unsubscribeSecurity?.();
}
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;
+107 -39
View File
@@ -1,4 +1,4 @@
import { Patch, ToastData, afterPatch, findInReactTree, findModuleChild, sleep } from 'decky-frontend-lib'; import { Patch, ToastData, afterPatch, findInReactTree, sleep } from 'decky-frontend-lib';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import Toast from './components/Toast'; import Toast from './components/Toast';
@@ -12,13 +12,18 @@ declare global {
} }
class Toaster extends Logger { class Toaster extends Logger {
private instanceRetPatch?: Patch; // private routerHook: RouterHook;
// private toasterState: DeckyToasterState = new DeckyToasterState();
private node: any; private node: any;
private rNode: any;
private settingsModule: any; private settingsModule: any;
private ready: boolean = false; private finishStartup?: () => void;
private ready: Promise<void> = new Promise((res) => (this.finishStartup = res));
private toasterPatch?: Patch;
constructor() { constructor() {
super('Toaster'); super('Toaster');
// this.routerHook = routerHook;
window.__TOASTER_INSTANCE?.deinit?.(); window.__TOASTER_INSTANCE?.deinit?.();
window.__TOASTER_INSTANCE = this; window.__TOASTER_INSTANCE = this;
@@ -26,55 +31,116 @@ class Toaster extends Logger {
} }
async init() { async init() {
// this.routerHook.addGlobalComponent('DeckyToaster', () => (
// <DeckyToasterStateContextProvider deckyToasterState={this.toasterState}>
// <DeckyToaster />
// </DeckyToasterStateContextProvider>
// ));
let instance: any; let instance: any;
const tree = (document.getElementById('root') as any)._reactRootContainer._internalRoot.current;
while (true) { const findToasterRoot = (currentNode: any, iters: number): any => {
instance = findInReactTree( if (iters >= 50) {
(document.getElementById('root') as any)._reactRootContainer._internalRoot.current, // currently 40
(x) => x?.memoizedProps?.className?.startsWith?.('toastmanager_ToastPlaceholder'), 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.',
); );
if (instance) break; await sleep(5000);
this.debug('finding instance'); instance = findToasterRoot(tree, 0);
await sleep(2000);
} }
this.node = instance.return;
this.node = instance.sibling.child; this.rNode = this.node.return;
let toast: any; let toast: any;
let renderedToast: ReactNode = null; let renderedToast: ReactNode = null;
afterPatch(this.node, 'type', (args: any[], ret: any) => { let innerPatched: any;
const currentToast = args[0].notification; const repatch = () => {
if (currentToast?.decky) { if (this.node && !this.node.type.decky) {
if (currentToast !== toast) { this.toasterPatch = afterPatch(this.node, 'type', (_: any, ret: any) => {
toast = currentToast; const inner = findInReactTree(ret.props.children, (x) => x?.props?.onDismiss);
renderedToast = <Toast toast={toast} />; if (innerPatched) {
} inner.type = innerPatched;
ret.props.children = renderedToast; } else {
} else { afterPatch(inner, 'type', (innerArgs: any, ret: any) => {
toast = null; const currentToast = innerArgs[0]?.notification;
renderedToast = null; 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; return ret;
}); };
this.settingsModule = findModuleChild((m) => {
if (typeof m !== 'object') return undefined; this.rNode.stateNode.shouldComponentUpdate = () => true;
for (let prop in m) { this.rNode.stateNode.forceUpdate();
if (typeof m[prop]?.settings && m[prop]?.communityPreferences) return m[prop]; delete this.rNode.stateNode.shouldComponentUpdate;
}
});
this.log('Initialized'); this.log('Initialized');
this.ready = true; this.finishStartup?.();
} }
async toast(toast: ToastData) { async toast(toast: ToastData) {
while (!this.ready) { // toast.duration = toast.duration || 5e3;
await sleep(100); // this.toasterState.addToast(toast);
} await this.ready;
const settings = this.settingsModule?.settings; const settings = this.settingsModule?.settings;
let toastData = { let toastData = {
nNotificationID: window.NotificationStore.m_nNextTestNotificationID++, nNotificationID: window.NotificationStore.m_nNextTestNotificationID++,
rtCreated: Date.now(), rtCreated: Date.now(),
eType: 15, eType: 15,
nToastDurationMS: toast.duration || 5e3, nToastDurationMS: toast.duration || (toast.duration = 5e3),
data: toast, data: toast,
decky: true, decky: true,
}; };
@@ -90,9 +156,11 @@ class Toaster extends Logger {
} }
deinit() { deinit() {
this.instanceRetPatch?.unpatch(); this.toasterPatch?.unpatch();
this.node && delete this.node.stateNode.render; this.node.alternate.type = this.node.type;
this.node && this.node.stateNode.forceUpdate(); delete this.rNode.stateNode.render;
this.ready = new Promise((res) => (this.finishStartup = res));
// this.routerHook.removeGlobalComponent('DeckyToaster');
} }
} }
+1 -1
View File
@@ -1,7 +1,7 @@
export enum Branches { export enum Branches {
Release, Release,
Prerelease, Prerelease,
Nightly, // Testing,
} }
export interface DeckyUpdater { export interface DeckyUpdater {
+5 -19
View File
@@ -1,25 +1,14 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
interface GetSettingArgs<T> { import { getSetting, setSetting } from '../settings';
key: string;
default: T;
}
interface SetSettingArgs<T> { export function useSetting<T>(key: string, def: T): [value: T, setValue: (value: T) => Promise<void>] {
key: string;
value: T;
}
export function useSetting<T>(key: string, def: T): [value: T | null, setValue: (value: T) => Promise<void>] {
const [value, setValue] = useState(def); const [value, setValue] = useState(def);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const res = (await window.DeckyPluginLoader.callServerMethod('get_setting', { const res = await getSetting<T>(key, def);
key, setValue(res);
default: def,
} as GetSettingArgs<T>)) as { result: T };
setValue(res.result);
})(); })();
}, []); }, []);
@@ -27,10 +16,7 @@ export function useSetting<T>(key: string, def: T): [value: T | null, setValue:
value, value,
async (val: T) => { async (val: T) => {
setValue(val); setValue(val);
await window.DeckyPluginLoader.callServerMethod('set_setting', { await setSetting(key, val);
key,
value: val,
} as SetSettingArgs<T>);
}, },
]; ];
} }
+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;
}