Compare commits

...

38 Commits

Author SHA1 Message Date
AAGaming 16681fabb5 fix http requests 2022-11-19 22:33:51 -05:00
AAGaming c210523a22 fix handleWarning in rollup config 2022-11-19 20:07:08 -05:00
Marco Rodolfi 5d8601347a Fix for wrong path for settings json files (#258)
Co-authored-by: AAGaming <aa@mail.catvibers.me>
2022-11-19 19:34:38 -05:00
AAGaming 1e02fcf394 fix broken trycatch causing occasional injection failures 2022-11-19 19:22:30 -05:00
TrainDoctor f923306a7f Update issue templates 2022-11-19 14:19:33 -08:00
TrainDoctor 478fe32527 Revert "Fix for setting json files ending up in ~/homebrew"
This reverts commit aec7063139.
2022-11-15 15:01:26 -08:00
AAGaming 50764600c8 Refactoring in preparation for WebSockets (#254)
* Fix injector race conditions

* add some more tasks

* hide useless rollup warnings

* goodbye to clientsession errors

* completely fix desktop mode switch race condition

* fix typos and TS warning in plugin error handler

* fix chown error

* start debugger if needed and not already started

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

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

* Fix imports and variable references

* Get data for all messages

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

* Loader updates service file during update!

* Testing update branch doesn't exist lol

* Update to dfl 3.7.12

* Fix services and add working service updater

* Revert services but replace their aliases

* Fix install scripts as well

* Move leftover service files to .systemd dir

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

* fix whitespace

* Remove unused imports

* Remove another un-used import

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

* better injection point

* finally fix dumb loading error

* fix QAM injection breaking after lock

* shut up typescript

* fix lock screen focusing issues

* Bring back the Valve toaster!

* Add support for stable steamos

* fix focus bug on lock screen but actually

* oops: remove extra console log

* shut up typescript again

* better fix for lockscreen bug

* better probably

* actually fix focus issues (WTF)

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

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

* tabshook changes, disable toaster for now

* prettier

* oops

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

also fix QAM not injecting sometimes

* remove extra logging

* add findSP, fix patch notes, fix vscode screwup

* fix patch notes

* show error when plugin frontends fail to load

* add get_tab_lambda

* add css and has_element helpers to Tab

* small modals fixup

* Don't forceUpdate QuickAccess on stable

* add routes prop used to get tabs component

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

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

* Update backend/browser.py

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

* Update backend/browser.py

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

* Update frontend/src/toaster.tsx

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

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

* Remove false logic

* look for plugins not having remote_binary in pkg

Co-authored-by: AAGaming <aa@mail.catvibers.me>
2022-10-23 13:41:12 -07:00
TrainDoctor 4ad15568cd Merge drop-legacy to main. 2022-10-23 13:04:00 -07:00
TrainDoctor 58849b3002 Reduce amount of actions taken when using updateandrun 2022-10-22 19:11:00 -07:00
TrainDoctor 6346da6fe5 Actually utilize the unload function 2022-10-22 18:44:44 -07:00
TrainDoctor af51a29055 Added unload hook for plugins. 2022-10-22 18:36:49 -07:00
TrainDoctor c546a818f1 Send version when asking for plugin list 2022-10-22 16:52:48 -07:00
TrainDoctor 0226bd2bf8 Update build.yml 2022-10-22 13:02:23 -07:00
42 changed files with 1421 additions and 646 deletions
+36
View File
@@ -0,0 +1,36 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
**Description**
[A clear and concise description of what the bug is.]
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
[A clear and concise description of what you expected to happen.]
**Screenshots**
[If applicable, add screenshots to help explain your problem.]
**Version information**
- SteamOS Version: ``[Run ``uname -a`` and place the output here. Leave the single quotations outside.]``
- Selected Update Channel: [Stable, Beta or Preview.]
**Logs**
[Please reboot your deck (if possible) when attempting to recreate the issue, then run
``cd ~ && journalctl -b0 -u plugin_loader.service > backendlog.txt``. This will save the log file to ``~`` aka ``/home/deck``. Please upload the file here in place of this textblock.]
**Additional context**
Have you modified the read-only filesystem at any point?
[Yes or No.]
+4 -4
View File
@@ -47,15 +47,15 @@ jobs:
with:
node-version: 18
- name: Set up Python 3.10 🐍
uses: actions/setup-python@v3
- name: Set up Python 3.10.2 🐍
uses: actions/setup-python@v4
with:
python-version: "3.10"
python-version: "3.10.2"
- name: Install Python dependencies ⬇️
run: |
python -m pip install --upgrade pip
pip install pyinstaller
pip install pyinstaller==5.5
[ -f requirements.txt ] && pip install -r requirements.txt
- name: Install JS dependencies ⬇️
+34 -7
View File
@@ -14,7 +14,9 @@
"label": "localrun",
"type": "shell",
"group": "none",
"dependsOn" : ["buildall"],
"dependsOn": [
"buildall"
],
"detail": "Check for local runs, create a plugins folder",
"command": "mkdir -p plugins",
"problemMatcher": []
@@ -48,6 +50,16 @@
"command": "cd frontend && pnpm i",
"problemMatcher": []
},
{
"script": "watch",
"type": "npm",
"path": "frontend",
"group": "build",
"problemMatcher": [],
"label": "watchfrontend",
"detail": "rollup -c -w",
"isBackground": true
},
{
"label": "buildfrontend",
"type": "npm",
@@ -55,8 +67,7 @@
"detail": "rollup -c",
"script": "build",
"path": "frontend",
"problemMatcher": [],
"problemMatcher": []
},
{
"label": "buildall",
@@ -95,7 +106,9 @@
"detail": "Run indev PluginLoader on Deck",
"type": "shell",
"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'",
"problemMatcher": []
},
@@ -108,6 +121,20 @@
"problemMatcher": []
},
// ALL-IN-ONES
{
"label": "deployandrun",
"detail": "Deploy and run, skipping JS build. Useful when combined with npm:watch",
"dependsOrder": "sequence",
"group": {
"kind": "build",
"isDefault": true
},
"dependsOn": [
"deploy",
"runpydeck"
],
"problemMatcher": []
},
{
"label": "updateremote",
"detail": "Build and deploy",
@@ -115,7 +142,7 @@
"group": "none",
"dependsOn": [
"buildall",
"deploy",
"deploy"
],
"problemMatcher": []
},
@@ -128,7 +155,7 @@
"isDefault": true
},
"dependsOn": [
"buildall",
"buildfrontend",
"deploy",
"runpydeck"
],
@@ -152,4 +179,4 @@
"problemMatcher": []
}
]
}
}
+2 -2
View File
@@ -17,7 +17,7 @@
## 📖 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).
@@ -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.
- 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
+16 -8
View File
@@ -16,7 +16,7 @@ from zipfile import ZipFile
# Local modules
from helpers import get_ssl_context, get_user, get_user_group, download_remote_binary_to_path
from injector import get_tab, inject_to_tab
from injector import get_gamepadui_tab
logger = getLogger("Browser")
@@ -57,7 +57,7 @@ class PluginBrowser:
if access(packageJsonPath, R_OK):
with open(packageJsonPath, 'r') as 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.
rc=call(["chmod", "-R", "777", pluginBasePath])
if access(pluginBasePath, W_OK):
@@ -97,14 +97,14 @@ class PluginBrowser:
plugin = json.load(f)
if plugin['name'] == name:
return path.join(self.plugin_path, folder)
return str(path.join(self.plugin_path, folder))
except:
logger.debug(f"skipping {folder}")
async def uninstall_plugin(self, name):
if self.loader.watcher:
self.loader.watcher.disabled = True
tab = await get_tab("SP")
tab = await get_gamepadui_tab()
try:
logger.info("uninstalling " + name)
logger.info(" at dir " + self.find_plugin_folder(name))
@@ -124,12 +124,15 @@ class PluginBrowser:
self.loader.watcher.disabled = False
async def _install(self, artifact, name, version, hash):
isInstalled = False
if self.loader.watcher:
self.loader.watcher.disabled = True
try:
await self.uninstall_plugin(name)
pluginFolderPath = self.find_plugin_folder(name)
if pluginFolderPath:
isInstalled = True
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})")
async with ClientSession() as client:
logger.debug(f"Fetching {artifact}")
@@ -139,6 +142,12 @@ class PluginBrowser:
data = await res.read()
logger.debug(f"Read {len(data)} bytes")
res_zip = BytesIO(data)
if isInstalled:
try:
logger.debug("Uninstalling existing plugin...")
await self.uninstall_plugin(name)
except:
logger.error(f"Plugin {name} could not be uninstalled.")
logger.debug("Unzipping...")
ret = self._unzip_to_plugin_dir(res_zip, name, hash)
if ret:
@@ -151,7 +160,6 @@ class PluginBrowser:
self.loader.plugins.pop(name, None)
await sleep(1)
self.loader.import_plugin(path.join(plugin_dir, "main.py"), plugin_dir)
# await inject_to_tab("SP", "window.syncDeckyPlugins()")
else:
logger.fatal(f"Failed Downloading Remote Binaries")
else:
@@ -164,7 +172,7 @@ class PluginBrowser:
async def request_plugin_install(self, artifact, name, version, hash):
request_id = str(time())
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.evaluate_js(f"DeckyPluginLoader.addPluginInstallPrompt('{name}', '{version}', '{request_id}', '{hash}')")
+21 -11
View File
@@ -1,3 +1,5 @@
import grp
import pwd
import re
import ssl
import subprocess
@@ -56,6 +58,14 @@ def get_user() -> str:
raise ValueError("helpers.get_user method called before user variable was set. Run helpers.set_user first.")
return user
#Get the user owner of the given file path.
def get_user_owner(file_path) -> str:
return pwd.getpwuid(os.stat(file_path).st_uid)[0]
#Get the user group of the given file path.
def get_user_group(file_path) -> str:
return grp.getgrgid(os.stat(file_path).st_gid)[0]
# Set the global user group. get_user must be called first
def set_user_group() -> str:
global group
@@ -94,18 +104,18 @@ async def download_remote_binary_to_path(url, binHash, path) -> bool:
if os.access(os.path.dirname(path), os.W_OK):
async with ClientSession() as client:
res = await client.get(url, ssl=get_ssl_context())
if res.status == 200:
data = BytesIO(await res.read())
remoteHash = sha256(data.getbuffer()).hexdigest()
if binHash == remoteHash:
data.seek(0)
with open(path, 'wb') as f:
f.write(data.getbuffer())
rv = True
else:
raise Exception(f"Fatal Error: Hash Mismatch for remote binary {path}@{url}")
if res.status == 200:
data = BytesIO(await res.read())
remoteHash = sha256(data.getbuffer()).hexdigest()
if binHash == remoteHash:
data.seek(0)
with open(path, 'wb') as f:
f.write(data.getbuffer())
rv = True
else:
rv = False
raise Exception(f"Fatal Error: Hash Mismatch for remote binary {path}@{url}")
else:
rv = False
except:
rv = False
+243 -133
View File
@@ -3,9 +3,12 @@
from asyncio import sleep
from logging import getLogger
from traceback import format_exc
from typing import List
from aiohttp import ClientSession
from aiohttp.client_exceptions import ClientConnectorError
from aiohttp import ClientSession, WSMsgType
from aiohttp.client_exceptions import ClientConnectorError, ClientOSError
from asyncio.exceptions import TimeoutError
import uuid
BASE_ADDRESS = "http://localhost:8080"
@@ -18,6 +21,7 @@ class Tab:
def __init__(self, res) -> None:
self.title = res["title"]
self.id = res["id"]
self.url = res["url"]
self.ws_url = res["webSocketDebuggerUrl"]
self.websocket = None
@@ -28,13 +32,16 @@ class Tab:
self.websocket = await self.client.ws_connect(self.ws_url)
async def close_websocket(self):
await self.websocket.close()
await self.client.close()
async def listen_for_message(self):
async for message in self.websocket:
data = message.json()
yield data
logger.warn(f"The Tab {self.title} socket has been disconnected while listening for messages.")
await self.close_websocket()
async def _send_devtools_cmd(self, dc, receive=True):
if self.websocket:
self.cmd_id += 1
@@ -48,20 +55,44 @@ class Tab:
raise RuntimeError("Websocket not opened")
async def evaluate_js(self, js, run_async=False, manage_socket=True, get_result=True):
if manage_socket:
await self.open_websocket()
try:
if manage_socket:
await self.open_websocket()
res = await self._send_devtools_cmd({
"method": "Runtime.evaluate",
"params": {
"expression": js,
"userGesture": True,
"awaitPromise": run_async
}
}, get_result)
res = await self._send_devtools_cmd({
"method": "Runtime.evaluate",
"params": {
"expression": js,
"userGesture": True,
"awaitPromise": run_async
}
}, get_result)
if manage_socket:
await self.close_websocket()
finally:
if manage_socket:
await self.close_websocket()
return res
async def has_global_var(self, var_name, manage_socket=True):
res = await self.evaluate_js(f"window['{var_name}'] !== null && window['{var_name}'] !== undefined", False, manage_socket)
if not "result" in res or not "result" in res["result"] or not "value" in res["result"]["result"]:
return False
return res["result"]["result"]["value"]
async def close(self, manage_socket=True):
try:
if manage_socket:
await self.open_websocket()
res = await self._send_devtools_cmd({
"method": "Page.close",
}, False)
finally:
if manage_socket:
await self.close_websocket()
return res
async def enable(self):
@@ -80,67 +111,83 @@ class Tab:
"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
"""
if manage_socket:
await self.open_websocket()
try:
if manage_socket:
await self.open_websocket()
await self._send_devtools_cmd({
"method": "Debugger.enable"
}, True)
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,
"expression": "location.reload();",
"userGesture": True,
"awaitPromise": False
}
}, False)
await self._send_devtools_cmd({
"method": "Debugger.removeBreakpoint",
"params": {
"breakpointId": breakpoint_res["result"]["breakpointId"]
}
}, 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)
for x in range(4):
await self._send_devtools_cmd({
"method": "Debugger.resume"
"method": "Debugger.removeBreakpoint",
"params": {
"breakpointId": breakpoint_res["result"]["breakpointId"]
}
}, False)
await self._send_devtools_cmd({
"method": "Debugger.disable"
}, True)
for x in range(4):
await self._send_devtools_cmd({
"method": "Debugger.resume"
}, False)
if manage_socket:
await self.close_websocket()
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):
@@ -176,32 +223,34 @@ class Tab:
(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
wrappedjs = """
function scriptFunc() {
{js}
}
}, get_result)
if (document.readyState === 'loading') {
addEventListener('DOMContentLoaded', () => {
scriptFunc();
});
} else {
scriptFunc();
}
""".format(js=js) if add_dom_wrapper else js
if manage_socket:
await self.close_websocket()
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):
@@ -214,18 +263,85 @@ class Tab:
The identifier of the script to remove (returned from `add_script_to_evaluate_on_new_document`)
"""
if manage_socket:
await self.open_websocket()
try:
if manage_socket:
await self.open_websocket()
res = await self._send_devtools_cmd({
"method": "Page.removeScriptToEvaluateOnNewDocument",
"params": {
"identifier": script_id
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
}
}, False)
if manage_socket:
await self.close_websocket()
async def remove_css(self, css_id, manage_socket=True):
try:
result = await self.evaluate_js(
f"""
(function() {{
let style = document.getElementById("{css_id}");
if (style.nodeName.toLowerCase() == 'style')
style.parentNode.removeChild(style);
}})()
""", False, manage_socket)
if "exceptionDetails" in result["result"]:
return {
"success": False,
"result": result
}
return {
"success": True
}
except Exception as e:
return {
"success": False,
"result": e
}
async def get_steam_resource(self, url):
res = await self.evaluate_js(f'(async function test() {{ return await (await fetch("{url}")).text() }})()', True)
@@ -235,63 +351,57 @@ class Tab:
return self.title
async def get_tabs():
async with ClientSession() as web:
res = {}
async def get_tabs() -> List[Tab]:
res = {}
while True:
try:
res = await web.get(f"{BASE_ADDRESS}/json")
except ClientConnectorError:
logger.debug("ClientConnectorError excepted.")
na = False
while True:
try:
async with ClientSession() as web:
res = await web.get(f"{BASE_ADDRESS}/json", timeout=3)
except ClientConnectorError:
if not na:
logger.debug("Steam isn't available yet. Wait for a moment...")
logger.error(format_exc())
await sleep(5)
else:
break
if res.status == 200:
r = await res.json()
return [Tab(i) for i in r]
na = True
await sleep(5)
except ClientOSError:
logger.warn(f"The request to {BASE_ADDRESS}/json was reset")
await sleep(1)
except TimeoutError:
logger.warn(f"The request to {BASE_ADDRESS}/json timed out")
await sleep(1)
else:
raise Exception(f"/json did not return 200. {await res.text()}")
break
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):
async def get_tab(tab_name) -> Tab:
tabs = await get_tabs()
tab = next((i for i in tabs if i.title == tab_name), None)
if not tab:
raise ValueError(f"Tab {tab_name} not found")
return tab
async def get_tab_lambda(test) -> Tab:
tabs = await get_tabs()
tab = next((i for i in tabs if test(i)), None)
if not tab:
raise ValueError(f"Tab not found by lambda")
return tab
async def get_gamepadui_tab() -> Tab:
tabs = await get_tabs()
tab = next((i for i in tabs if ("https://steamloopback.host/routes/" in i.url and (i.title == "Steam" or i.title == "SP"))), None)
if not tab:
raise ValueError(f"GamepadUI Tab not found")
return tab
async def inject_to_tab(tab_name, js, run_async=False):
tab = await get_tab(tab_name)
return await tab.evaluate_js(js, run_async)
async def tab_has_global_var(tab_name, var_name):
try:
tab = await get_tab(tab_name)
except ValueError:
return False
res = await tab.evaluate_js(f"window['{var_name}'] !== null && window['{var_name}'] !== undefined", False)
if not "result" in res or not "result" in res["result"] or not "value" in res["result"]["result"]:
return False
return res["result"]["result"]["value"]
async def tab_has_element(tab_name, element_name):
try:
tab = await get_tab(tab_name)
except ValueError:
return False
res = await tab.evaluate_js(f"document.getElementById('{element_name}') != null", False)
if not "result" in res or not "result" in res["result"] or not "value" in res["result"]["result"]:
return False
return res["result"]["result"]["value"]
+8 -6
View File
@@ -15,7 +15,7 @@ try:
except UnsupportedLibc:
from watchdog.observers.fsevents import FSEventsObserver as Observer
from injector import get_tab, inject_to_tab
from injector import get_tab, get_gamepadui_tab
from plugin import PluginWrapper
@@ -121,7 +121,7 @@ class Loader:
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")
def import_plugin(self, file, plugin_directory, refresh=False):
def import_plugin(self, file, plugin_directory, refresh=False, batch=False):
try:
plugin = PluginWrapper(file, plugin_directory, self.plugin_path)
if plugin.name in self.plugins:
@@ -135,13 +135,15 @@ class Loader:
self.logger.info(f"Plugin {plugin.name} is passive")
self.plugins[plugin.name] = plugin.start()
self.logger.info(f"Loaded {plugin.name}")
self.loop.create_task(self.dispatch_plugin(plugin.name if not 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:
self.logger.error(f"Could not load {file}. {e}")
print_exc()
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):
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"))]
for directory in directories:
self.logger.info(f"found plugin: {directory}")
self.import_plugin(path.join(self.plugin_path, directory, "main.py"), directory)
self.import_plugin(path.join(self.plugin_path, directory, "main.py"), directory, False, True)
async def handle_reloads(self):
while True:
@@ -206,7 +208,7 @@ class Loader:
return web.Response(text=ret)
async def get_steam_resource(self, request):
tab = await get_tab("QuickAccess")
tab = await get_tab("SP")
try:
return web.Response(text=await tab.get_steam_resource(f"https://steamloopback.host/{request.match_info['path']}"), content_type="text/html")
except Exception as e:
+82 -32
View File
@@ -4,7 +4,7 @@ from subprocess import call
if hasattr(sys, '_MEIPASS'):
call(['chmod', '-R', '755', sys._MEIPASS])
# 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 logging import DEBUG, INFO, basicConfig, getLogger
from os import getenv, chmod, path
@@ -12,7 +12,7 @@ from traceback import format_exc
import aiohttp_cors
# Partial imports
from aiohttp import ClientSession
from aiohttp import client_exceptions, WSMsgType
from aiohttp.web import Application, Response, get, run_app, static
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,
get_home_path, get_homebrew_path, get_user,
get_user_group, set_user, set_user_group,
stop_systemd_unit)
from injector import inject_to_tab, tab_has_global_var
stop_systemd_unit, start_systemd_unit)
from injector import get_gamepadui_tab, Tab, get_tabs
from loader import Loader
from settings import SettingsManager
from updater import Updater
@@ -56,15 +56,15 @@ basicConfig(
logger = getLogger("Main")
async def chown_plugin_dir(_):
async def chown_plugin_dir():
code_chown = call(["chown", "-R", USER+":"+GROUP, CONFIG["plugin_path"]])
code_chmod = call(["chmod", "-R", "555", CONFIG["plugin_path"]])
if code_chown != 0 or code_chmod != 0:
logger.error(f"chown/chmod exited with a non-zero exit code (chown: {code_chown}, chmod: {code_chmod})")
class PluginManager:
def __init__(self) -> None:
self.loop = get_event_loop()
def __init__(self, loop) -> None:
self.loop = loop
self.web_app = Application()
self.web_app.middlewares.append(csrf_middleware)
self.cors = aiohttp_cors.setup(self.web_app, defaults={
@@ -81,12 +81,19 @@ class PluginManager:
self.updater = Updater(self)
jinja_setup(self.web_app)
if CONFIG["chown_plugin_path"] == True:
self.web_app.on_startup.append(chown_plugin_dir)
self.loop.create_task(self.loader_reinjector())
self.loop.create_task(self.load_plugins())
if not self.settings.getSetting("cef_forward", False):
self.loop.create_task(stop_systemd_unit(REMOTE_DEBUGGER_UNIT))
async def startup(_):
if self.settings.getSetting("cef_forward", False):
self.loop.create_task(start_systemd_unit(REMOTE_DEBUGGER_UNIT))
else:
self.loop.create_task(stop_systemd_unit(REMOTE_DEBUGGER_UNIT))
if CONFIG["chown_plugin_path"] == True:
chown_plugin_dir()
self.loop.create_task(self.loader_reinjector())
self.loop.create_task(self.load_plugins())
self.web_app.on_startup.append(startup)
self.loop.set_exception_handler(self.exception_handler)
self.web_app.add_routes([get("/auth/token", self.get_auth_token)])
@@ -103,32 +110,73 @@ class PluginManager:
async def get_auth_token(self, request):
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):
await self.wait_for_server()
# await self.wait_for_server()
logger.debug("Loading plugins")
self.plugin_loader.import_plugins()
# await inject_to_tab("SP", "window.syncDeckyPlugins();")
async def loader_reinjector(self):
await sleep(2)
await self.inject_javascript()
while True:
await sleep(5)
if not await tab_has_global_var("SP", "deckyHasLoaded"):
logger.info("Plugin loader isn't present in Steam anymore, reinjecting...")
await self.inject_javascript()
tab = None
nf = False
dc = False
while not tab:
try:
tab = await get_gamepadui_tab()
except (client_exceptions.ClientConnectorError, client_exceptions.ServerDisconnectedError):
if not dc:
logger.debug("Couldn't connect to debugger, waiting...")
dc = True
pass
except ValueError:
if not nf:
logger.debug("Couldn't find GamepadUI tab, waiting...")
nf = True
pass
if not tab:
await sleep(5)
await tab.open_websocket()
await tab.enable()
await self.inject_javascript(tab, True)
try:
async for msg in tab.listen_for_message():
# this gets spammed a lot
if msg.get("method", None) != "Page.navigatedWithinDocument":
logger.debug("Page event: " + str(msg.get("method", None)))
if msg.get("method", None) == "Page.domContentEventFired":
if not await tab.has_global_var("deckyHasLoaded", False):
await self.inject_javascript(tab)
if msg.get("method", None) == "Inspector.detached":
logger.info("CEF has requested that we detach.")
await tab.close_websocket()
break
# If this is a forceful disconnect the loop will just stop without any failure message. In this case, injector.py will handle this for us so we don't need to close the socket.
# This is because of https://github.com/aio-libs/aiohttp/blob/3ee7091b40a1bc58a8d7846e7878a77640e96996/aiohttp/client_ws.py#L321
logger.info("CEF has disconnected...")
# At this point the loop starts again and we connect to the freshly started Steam client once it is ready.
except Exception as e:
logger.error("Exception while reading page events " + format_exc())
await tab.close_websocket()
pass
# while True:
# await sleep(5)
# if not await tab.has_global_var("deckyHasLoaded", False):
# logger.info("Plugin loader isn't present in Steam anymore, reinjecting...")
# await self.inject_javascript(tab)
async def inject_javascript(self, request=None):
async def inject_javascript(self, tab: Tab, first=False, request=None):
logger.info("Loading Decky frontend!")
try:
await inject_to_tab("SP", "try{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:
logger.info("Failed to inject JavaScript into tab\n" + format_exc())
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)
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())
exit(0)
async def _unload(self):
try:
self.log.info("Attempting to unload " + self.name + "\n")
if hasattr(self.Plugin, "_unload"):
await self.Plugin._unload(self.Plugin)
except:
self.log.error("Failed to unload " + self.name + "!\n" + format_exc())
exit(0)
async def _setup_socket(self):
self.socket = await start_unix_server(self._listen_for_method_call, path=self.socket_addr, limit=BUFFER_LIMIT)
@@ -90,6 +99,7 @@ class PluginWrapper:
break
data = loads(line.decode("utf-8"))
if "stop" in data:
await self._unload()
get_event_loop().stop()
while get_event_loop().is_running():
await sleep(0)
+20 -4
View File
@@ -1,20 +1,36 @@
import imp
from json import dump, load
from os import mkdir, path
from os import mkdir, path, listdir, rename
from shutil import chown
from helpers import get_home_path, get_homebrew_path, get_user, set_user
from helpers import get_home_path, get_homebrew_path, get_user, set_user, get_user_owner
class SettingsManager:
def __init__(self, name, settings_directory = None) -> None:
set_user()
USER = get_user()
wrong_dir = get_homebrew_path(get_home_path(USER))
if settings_directory == None:
settings_directory = get_homebrew_path(get_home_path(USER))
settings_directory = path.join(wrong_dir, "settings")
self.path = path.join(settings_directory, name + ".json")
#Create the folder with the correct permission
if not path.exists(settings_directory):
mkdir(settings_directory)
chown(settings_directory, USER, USER)
#Copy all old settings file in the root directory to the correct folder
for file in listdir(wrong_dir):
if file.endswith(".json"):
rename(path.join(wrong_dir,file),
path.join(settings_directory, file))
self.path = path.join(settings_directory, name + ".json")
#If the owner of the settings directory is not the user, then set it as the user:
if get_user_owner(settings_directory) != USER:
chown(settings_directory, USER, USER)
self.settings = {}
+69 -28
View File
@@ -1,3 +1,5 @@
import os
import shutil
import uuid
from asyncio import sleep
from ensurepip import version
@@ -9,7 +11,7 @@ from subprocess import call
from aiohttp import ClientSession, web
import helpers
from injector import get_tab, inject_to_tab
from injector import get_gamepadui_tab, inject_to_tab
from settings import SettingsManager
logger = getLogger("Updater")
@@ -79,6 +81,20 @@ class Updater:
async def _get_branch(self, manager: SettingsManager):
return self.get_branch(manager)
# retrieve relevant service file's url for each branch
def get_service_url(self):
logger.debug("Getting service URL")
branch = self.get_branch(self.context.settings)
match branch:
case 0:
url = "https://raw.githubusercontent.com/SteamDeckHomebrew/decky-loader/main/dist/plugin_loader-release.service"
case 1 | 2:
url = "https://raw.githubusercontent.com/SteamDeckHomebrew/decky-loader/main/dist/plugin_loader-prerelease.service"
case _:
logger.error("You have an invalid branch set... Defaulting to prerelease service, please send the logs to the devs!")
url = "https://raw.githubusercontent.com/SteamDeckHomebrew/decky-loader/main/dist/plugin_loader-prerelease.service"
return str(url)
async def get_version(self):
if self.localVer:
return {
@@ -96,25 +112,20 @@ class Updater:
async with ClientSession() as web:
async with web.request("GET", "https://api.github.com/repos/SteamDeckHomebrew/decky-loader/releases", ssl=helpers.get_ssl_context()) as res:
remoteVersions = await res.json()
self.allRemoteVers = remoteVersions
logger.debug("determining release type to find, branch is %i" % selectedBranch)
if selectedBranch == 0:
logger.debug("release type: release")
self.remoteVer = next(filter(lambda ver: ver["tag_name"].startswith("v") and not ver["prerelease"] and ver["tag_name"], remoteVersions), None)
elif selectedBranch == 1:
logger.debug("release type: pre-release")
self.remoteVer = next(filter(lambda ver: ver["prerelease"] and ver["tag_name"].startswith("v") and ver["tag_name"].find("-pre"), remoteVersions), None)
# elif selectedBranch == 2:
# logger.debug("release type: nightly")
# self.remoteVer = next(filter(lambda ver: ver["prerelease"] and ver["tag_name"].startswith("v") and ver["tag_name"].find("nightly"), remoteVersions), None)
else:
logger.error("release type: NOT FOUND")
raise ValueError("no valid branch found")
# 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)
self.allRemoteVers = remoteVersions
logger.debug("determining release type to find, branch is %i" % selectedBranch)
if selectedBranch == 0:
logger.debug("release type: release")
self.remoteVer = next(filter(lambda ver: ver["tag_name"].startswith("v") and not ver["prerelease"] and ver["tag_name"], remoteVersions), None)
elif selectedBranch == 1:
logger.debug("release type: pre-release")
self.remoteVer = next(filter(lambda ver: ver["prerelease"] and ver["tag_name"].startswith("v") and ver["tag_name"].find("-pre"), remoteVersions), None)
else:
logger.error("release type: NOT FOUND")
raise ValueError("no valid branch found")
logger.info("Updated remote version information")
tab = await get_gamepadui_tab()
await tab.evaluate_js(f"window.DeckyPluginLoader.notifyUpdates()", False, True, False)
return await self.get_version()
async def version_reloader(self):
@@ -127,14 +138,44 @@ class Updater:
await sleep(60 * 60 * 6) # 6 hours
async def do_update(self):
logger.debug("Starting update.")
version = self.remoteVer["tag_name"]
download_url = self.remoteVer["assets"][0]["browser_download_url"]
service_url = self.get_service_url()
logger.debug("Retrieved service URL")
tab = await get_tab("SP")
tab = await get_gamepadui_tab()
await tab.open_websocket()
async with ClientSession() as web:
logger.debug("Downloading systemd service")
# download the relevant systemd service depending upon branch
async with web.request("GET", service_url, ssl=helpers.get_ssl_context(), allow_redirects=True) as res:
logger.debug("Downloading service file")
data = await res.content.read()
logger.debug(str(data))
service_file_path = path.join(getcwd(), "plugin_loader.service")
try:
with open(path.join(getcwd(), "plugin_loader.service"), "wb") as out:
out.write(data)
except Exception as e:
logger.error(f"Error at %s", exc_info=e)
with open(path.join(getcwd(), "plugin_loader.service"), 'r') as service_file:
service_data = service_file.read()
service_data = service_data.replace("${HOMEBREW_FOLDER}", "/home/"+helpers.get_user()+"/homebrew")
with open(path.join(getcwd(), "plugin_loader.service"), 'w') as service_file:
service_file.write(service_data)
logger.debug("Saved service file")
logger.debug("Copying service file over current file.")
shutil.copy(service_file_path, "/etc/systemd/system/plugin_loader.service")
if not os.path.exists(path.join(getcwd(), ".systemd")):
os.mkdir(path.join(getcwd(), ".systemd"))
shutil.move(service_file_path, path.join(getcwd(), ".systemd")+"/plugin_loader.service")
logger.debug("Downloading binary")
async with web.request("GET", download_url, ssl=helpers.get_ssl_context(), allow_redirects=True) as res:
total = int(res.headers.get('content-length', 0))
# we need to not delete the binary until we have downloaded the new binary!
try:
remove(path.join(getcwd(), "PluginLoader"))
except:
@@ -150,14 +191,14 @@ class Updater:
self.context.loop.create_task(tab.evaluate_js(f"window.DeckyUpdater.updateProgress({new_progress})", False, False, False))
progress = new_progress
with open(path.join(getcwd(), ".loader.version"), "w") as out:
out.write(version)
with open(path.join(getcwd(), ".loader.version"), "w") as out:
out.write(version)
call(['chmod', '+x', path.join(getcwd(), "PluginLoader")])
logger.info("Updated loader installation.")
await tab.evaluate_js("window.DeckyUpdater.finish()", False, False)
await tab.client.close()
call(['chmod', '+x', path.join(getcwd(), "PluginLoader")])
logger.info("Updated loader installation.")
await tab.evaluate_js("window.DeckyUpdater.finish()", False, False)
await self.do_restart()
await tab.close_websocket()
async def do_restart(self):
call(["systemctl", "daemon-reload"])
+21 -20
View File
@@ -7,7 +7,7 @@ from asyncio import sleep, start_server, gather, open_connection
from aiohttp import ClientSession, web
from logging import getLogger
from injector import inject_to_tab, get_tab
from injector import inject_to_tab, get_gamepadui_tab
import helpers
import subprocess
@@ -80,12 +80,13 @@ class Utilities:
async def http_request(self, method="", url="", **kwargs):
async with ClientSession() as web:
async with web.request(method, url, ssl=helpers.get_ssl_context(), **kwargs) as res:
return {
"status": res.status,
"headers": dict(res.headers),
"body": await res.text()
}
res = await web.request(method, url, ssl=helpers.get_ssl_context(), **kwargs)
text = await res.text()
return {
"status": res.status,
"headers": dict(res.headers),
"body": text
}
async def ping(self, **kwargs):
return "pong"
@@ -241,17 +242,17 @@ class Utilities:
if ip != None:
self.logger.info("Connecting to React DevTools at " + ip)
async with ClientSession() as web:
async with web.request("GET", "http://" + ip + ":8097", ssl=helpers.get_ssl_context()) as res:
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_tab("SP")
# RDT needs to load before React itself to work.
result = await tab.reload_and_evaluate(script)
self.logger.info(result)
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")
@@ -259,7 +260,7 @@ class Utilities:
async def disable_rdt(self):
self.logger.info("Disabling React DevTools")
tab = await get_tab("SP")
tab = await get_gamepadui_tab()
self.rdt_script_id = None
await tab.evaluate_js("location.reload();", False, True, False)
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)"
HOMEBREW_FOLDER="${USER_DIR}/homebrew"
# # Create folder structure
rm -rf ${HOMEBREW_FOLDER}/services
# 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"
@@ -26,10 +26,14 @@ systemctl --user disable plugin_loader 2> /dev/null
systemctl stop plugin_loader 2> /dev/null
systemctl disable plugin_loader 2> /dev/null
rm -f /etc/systemd/system/plugin_loader.service
cat > /etc/systemd/system/plugin_loader.service <<- EOM
curl -L https://raw.githubusercontent.com/SteamDeckHomebrew/decky-loader/main/dist/plugin_loader-prerelease.service --output ${HOMEBREW_FOLDER}/services/plugin_loader-prerelease.service
cat > "${HOMEBREW_FOLDER}/services/plugin_loader-backup.service" <<- EOM
[Unit]
Description=SteamDeck Plugin Loader
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=root
@@ -41,6 +45,22 @@ Environment=LOG_LEVEL=DEBUG
[Install]
WantedBy=multi-user.target
EOM
if [[ -f "${HOMEBREW_FOLDER}/services/plugin_loader-prerelease.service" ]]; then
printf "Grabbed latest prerelease service.\n"
sed -i -e "s|\${HOMEBREW_FOLDER}|${HOMEBREW_FOLDER}|" "${HOMEBREW_FOLDER}/services/plugin_loader-prerelease.service"
cp -f "${HOMEBREW_FOLDER}/services/plugin_loader-prerelease.service" "/etc/systemd/system/plugin_loader.service"
else
printf "Could not curl latest prerelease systemd service, using built-in service as a backup!\n"
rm -f "/etc/systemd/system/plugin_loader.service"
cp "${HOMEBREW_FOLDER}/services/plugin_loader-backup.service" "/etc/systemd/system/plugin_loader.service"
fi
mkdir -p ${HOMEBREW_FOLDER}/services/.systemd
cp ${HOMEBREW_FOLDER}/services/plugin_loader-prerelease.service ${HOMEBREW_FOLDER}/services/.systemd/plugin_loader-prerelease.service
cp ${HOMEBREW_FOLDER}/services/plugin_loader-backup.service ${HOMEBREW_FOLDER}/services/.systemd/plugin_loader-backup.service
rm ${HOMEBREW_FOLDER}/services/plugin_loader-backup.service ${HOMEBREW_FOLDER}/services/plugin_loader-prerelease.service
systemctl daemon-reload
systemctl start plugin_loader
systemctl enable plugin_loader
+23 -2
View File
@@ -26,10 +26,14 @@ systemctl --user disable plugin_loader 2> /dev/null
systemctl stop plugin_loader 2> /dev/null
systemctl disable plugin_loader 2> /dev/null
rm -f "/etc/systemd/system/plugin_loader.service"
cat > "/etc/systemd/system/plugin_loader.service" <<- EOM
curl -L https://raw.githubusercontent.com/SteamDeckHomebrew/decky-loader/main/dist/plugin_loader-release.service --output ${HOMEBREW_FOLDER}/services/plugin_loader-release.service
cat > "${HOMEBREW_FOLDER}/services/plugin_loader-backup.service" <<- EOM
[Unit]
Description=SteamDeck Plugin Loader
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=root
@@ -37,9 +41,26 @@ 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
EOM
if [[ -f "${HOMEBREW_FOLDER}/services/plugin_loader-release.service" ]]; then
printf "Grabbed latest release service.\n"
sed -i -e "s|\${HOMEBREW_FOLDER}|${HOMEBREW_FOLDER}|" "${HOMEBREW_FOLDER}/services/plugin_loader-release.service"
cp -f "${HOMEBREW_FOLDER}/services/plugin_loader-release.service" "/etc/systemd/system/plugin_loader.service"
else
printf "Could not curl latest release systemd service, using built-in service as a backup!\n"
rm -f "/etc/systemd/system/plugin_loader.service"
cp "${HOMEBREW_FOLDER}/services/plugin_loader-backup.service" "/etc/systemd/system/plugin_loader.service"
fi
mkdir -p ${HOMEBREW_FOLDER}/services/.systemd
cp ${HOMEBREW_FOLDER}/services/plugin_loader-release.service ${HOMEBREW_FOLDER}/services/.systemd/plugin_loader-release.service
cp ${HOMEBREW_FOLDER}/services/plugin_loader-backup.service ${HOMEBREW_FOLDER}/services/.systemd/plugin_loader-backup.service
rm ${HOMEBREW_FOLDER}/services/plugin_loader-backup.service ${HOMEBREW_FOLDER}/services/plugin_loader-release.service
systemctl daemon-reload
systemctl start plugin_loader
systemctl enable plugin_loader
+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": {
"decky-frontend-lib": "^3.6.0",
"decky-frontend-lib": "^3.7.14",
"react-file-icon": "^1.2.0",
"react-icons": "^4.4.0",
"react-markdown": "^8.0.3",
+4 -10
View File
@@ -10,7 +10,7 @@ specifiers:
'@types/react-file-icon': ^1.0.1
'@types/react-router': 5.1.18
'@types/webpack': ^5.28.0
decky-frontend-lib: ^3.6.0
decky-frontend-lib: ^3.7.14
husky: ^8.0.1
import-sort-style-module: ^6.0.0
inquirer: ^8.2.4
@@ -30,7 +30,7 @@ specifiers:
typescript: ^4.7.4
dependencies:
decky-frontend-lib: 3.6.0
decky-frontend-lib: 3.7.14
react-file-icon: 1.2.0_wcqkhtmu7mswc6yz4uyexck3ty
react-icons: 4.4.0_react@16.14.0
react-markdown: 8.0.3_vshvapmxg47tngu7tvrsqpq55u
@@ -944,10 +944,8 @@ packages:
dependencies:
ms: 2.1.2
/decky-frontend-lib/3.6.0:
resolution: {integrity: sha512-X3VbTbmW7TnBwPW0ui0xjSVoa2UsuKPwI6nFi7LY2ZzmNytCfszk+ZfJSBm2lD2fqV+btqJzr0qFnWFl+bgjEA==}
dependencies:
minimist: 1.2.7
/decky-frontend-lib/3.7.14:
resolution: {integrity: sha512-ShAoP3VqiwkJYukDBHsOF9fk7wYw0VaKpHw6j9WdzLxwZwBcg0J7QBNIFYP3nfA0UgEwSJVEg/22kONiplipmA==}
dev: false
/decode-named-character-reference/1.0.2:
@@ -1944,10 +1942,6 @@ packages:
brace-expansion: 1.1.11
dev: true
/minimist/1.2.7:
resolution: {integrity: sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==}
dev: false
/mri/1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'}
+9
View File
@@ -7,6 +7,11 @@ import replace from '@rollup/plugin-replace';
import typescript from '@rollup/plugin-typescript';
import { defineConfig } from 'rollup';
const hiddenWarnings = [
"THIS_IS_UNDEFINED",
"EVAL"
];
export default defineConfig({
input: 'src/index.tsx',
plugins: [
@@ -35,5 +40,9 @@ export default defineConfig({
chunkFileNames: (chunkInfo) => {
return 'chunk-[hash].js'
}
},
onwarn: function ( message, handleWarning ) {
if (hiddenWarnings.some(warning => message.code === warning)) return;
handleWarning(message);
}
});
@@ -0,0 +1,74 @@
import { FC, createContext, useContext, useEffect, useState } from 'react';
interface PublicDeckyGlobalComponentsState {
components: Map<string, FC>;
}
export class DeckyGlobalComponentsState {
// TODO a set would be better
private _components = new Map<string, FC>();
public eventBus = new EventTarget();
publicState(): PublicDeckyGlobalComponentsState {
return { components: this._components };
}
addComponent(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 ReactMarkdown, { Options as ReactMarkdownOptions } from 'react-markdown';
import remarkGfm from 'remark-gfm';
@@ -21,8 +21,8 @@ const Markdown: FunctionComponent<MarkdownProps> = (props) => {
<Focusable
onActivate={() => {}}
onOKButton={() => {
aRef?.current?.click();
props.onDismiss?.();
Router.NavigateToExternalWeb(aRef.current!.href);
}}
style={{ display: 'inline' }}
>
@@ -1,13 +1,21 @@
import { FC, createContext, useContext } from 'react';
import { FC, createContext, useContext, useState } from 'react';
const QuickAccessVisibleState = createContext<boolean>(true);
export const useQuickAccessVisible = () => useContext(QuickAccessVisibleState);
interface Props {
visible: boolean;
}
export const QuickAccessVisibleStateProvider: FC<Props> = ({ children, visible }) => {
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';
interface ToastProps {
toast: {
data: ToastData;
nToastDurationMS: number;
};
toast: ToastData;
}
const toastClasses = findModule((mod) => {
export const toastClasses = findModule((mod) => {
if (typeof mod !== 'object') return false;
if (mod.ToastPlaceholder) {
@@ -31,21 +28,17 @@ const templateClasses = findModule((mod) => {
const Toast: FunctionComponent<ToastProps> = ({ toast }) => {
return (
<div
style={{ '--toast-duration': `${toast.nToastDurationMS}ms` } as React.CSSProperties}
className={toastClasses.toastEnter}
style={{ '--toast-duration': `${toast.duration}ms` } as React.CSSProperties}
onClick={toast.onClick}
className={joinClassNames(templateClasses.ShortTemplate, toast.className || '')}
>
<div
onClick={toast.data.onClick}
className={joinClassNames(templateClasses.ShortTemplate, toast.data.className || '')}
>
{toast.data.logo && <div className={templateClasses.StandardLogoDimensions}>{toast.data.logo}</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>
{toast.logo && <div className={templateClasses.StandardLogoDimensions}>{toast.logo}</div>}
<div className={joinClassNames(templateClasses.Content, toast.contentClassName || '')}>
<div className={templateClasses.Header}>
{toast.icon && <div className={templateClasses.Icon}>{toast.icon}</div>}
<div className={templateClasses.Title}>{toast.title}</div>
</div>
<div className={templateClasses.Body}>{toast.body}</div>
</div>
</div>
);
@@ -10,7 +10,7 @@ const logger = new Logger('BranchSelect');
enum UpdateBranch {
Stable,
Prerelease,
// Nightly,
// Testing,
}
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 { VerInfo, callUpdaterMethod, finishUpdate } from '../../../../updater';
import { findSP } from '../../../../utils/windows';
import { useDeckyState } from '../../../DeckyState';
import InlinePatchNotes from '../../../patchnotes/InlinePatchNotes';
import WithSuspense from '../../../WithSuspense';
@@ -21,6 +22,7 @@ import WithSuspense from '../../../WithSuspense';
const MarkdownRenderer = lazy(() => import('../../../Markdown'));
function PatchNotesModal({ versionInfo, closeModal }: { versionInfo: VerInfo | null; closeModal?: () => {} }) {
const SP = findSP();
return (
<Focusable onCancelButton={closeModal}>
<FocusRing>
@@ -50,12 +52,13 @@ function PatchNotesModal({ versionInfo, closeModal }: { versionInfo: VerInfo | n
)}
fnGetId={(id) => id}
nNumItems={versionInfo?.all?.length}
nHeight={window.innerHeight - 40}
nItemHeight={window.innerHeight - 40}
nHeight={SP.innerHeight - 40}
nItemHeight={SP.innerHeight - 40}
nItemMarginX={0}
initialColumn={0}
autoFocus={true}
fnGetColumnWidth={() => window.innerWidth}
fnGetColumnWidth={() => SP.innerWidth}
name="Decky Updates"
/>
</FocusRing>
</Focusable>
@@ -5,6 +5,7 @@ import { FaShapes, FaTools } from 'react-icons/fa';
import { installFromURL } from '../../../../store';
import BranchSelect from './BranchSelect';
import RemoteDebuggingSettings from './RemoteDebugging';
import StoreSelect from './StoreSelect';
import UpdaterSettings from './Updater';
export default function GeneralSettings({
@@ -15,20 +16,12 @@ export default function GeneralSettings({
setIsDeveloper: (val: boolean) => void;
}) {
const [pluginURL, setPluginURL] = useState('');
// const [checked, setChecked] = useState(false); // store these in some kind of State instead
return (
<div>
{/* <Field
label="A Toggle with an icon"
icon={<FaShapes style={{ display: 'block' }} />}
>
<Toggle
value={checked}
onChange={(e) => setChecked(e)}
/>
</Field> */}
<UpdaterSettings />
<BranchSelect />
<StoreSelect />
<RemoteDebuggingSettings />
<Field
label="Developer mode"
+11 -59
View File
@@ -11,17 +11,10 @@ import {
} from 'decky-frontend-lib';
import { FC, useRef, useState } from 'react';
import {
LegacyStorePlugin,
StorePlugin,
StorePluginVersion,
isLegacyPlugin,
requestLegacyPluginInstall,
requestPluginInstall,
} from '../../store';
import { StorePlugin, StorePluginVersion, requestPluginInstall } from '../../store';
interface PluginCardProps {
plugin: StorePlugin | LegacyStorePlugin;
plugin: StorePlugin;
}
const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
@@ -63,22 +56,13 @@ const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
}}
>
<div className="deckyStoreCardHeader" style={{ display: 'flex', alignItems: 'center' }}>
<a
<div
style={{ fontSize: '18pt', padding: '10px' }}
className={joinClassNames(staticClasses.Text)}
// onClick={() => Router.NavigateToExternalWeb('https://github.com/' + plugin.artifact)}
>
{isLegacyPlugin(plugin) ? (
<div className="deckyStoreCardNameContainer">
<span className="deckyStoreCardLegacyRepoOwner" style={{ color: 'grey' }}>
{plugin.artifact.split('/')[0]}/
</span>
{plugin.artifact.split('/')[1]}
</div>
) : (
plugin.name
)}
</a>
{plugin.name}
</div>
</div>
<div
style={{
@@ -94,17 +78,7 @@ const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
width: 'auto',
height: '160px',
}}
src={
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`
}
src={plugin.image_url}
/>
<div
style={{
@@ -152,19 +126,6 @@ const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
{tag == 'root' ? 'Requires root' : tag}
</span>
))}
{isLegacyPlugin(plugin) && (
<span
className="deckyStoreCardTag deckyStoreCardLegacyTag"
style={{
color: '#232120',
padding: '5px',
borderRadius: '5px',
background: '#EDE841',
}}
>
legacy
</span>
)}
</p>
</div>
</div>
@@ -194,11 +155,7 @@ const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
<DialogButton
className="deckyStoreCardInstallButton"
ref={buttonRef}
onClick={() =>
isLegacyPlugin(plugin)
? requestLegacyPluginInstall(plugin, Object.keys(plugin.versions)[selectedOption])
: requestPluginInstall(plugin.name, plugin.versions[selectedOption])
}
onClick={() => requestPluginInstall(plugin.name, plugin.versions[selectedOption])}
>
Install
</DialogButton>
@@ -211,15 +168,10 @@ const PluginCard: FC<PluginCardProps> = ({ plugin }) => {
>
<Dropdown
rgOptions={
(isLegacyPlugin(plugin)
? Object.keys(plugin.versions).map((v, k) => ({
data: k,
label: v,
}))
: plugin.versions.map((version: StorePluginVersion, index) => ({
data: index,
label: version.name,
}))) as SingleDropdownOption[]
plugin.versions.map((version: StorePluginVersion, index) => ({
data: index,
label: version.name,
})) as SingleDropdownOption[]
}
strDefaultLabel={'Select a version'}
selectedOption={selectedOption}
+1 -12
View File
@@ -2,14 +2,13 @@ import { SteamSpinner } from 'decky-frontend-lib';
import { FC, useEffect, useState } from 'react';
import Logger from '../../logger';
import { LegacyStorePlugin, StorePlugin, getLegacyPluginList, getPluginList } from '../../store';
import { StorePlugin, getPluginList } from '../../store';
import PluginCard from './PluginCard';
const logger = new Logger('FilePicker');
const StorePage: FC<{}> = () => {
const [data, setData] = useState<StorePlugin[] | null>(null);
const [legacyData, setLegacyData] = useState<LegacyStorePlugin[] | null>(null);
useEffect(() => {
(async () => {
@@ -17,11 +16,6 @@ const StorePage: FC<{}> = () => {
logger.log('got data!', res);
setData(res);
})();
(async () => {
const res = await getLegacyPluginList();
logger.log('got legacy data!', res);
setLegacyData(res);
})();
}, []);
return (
@@ -49,11 +43,6 @@ const StorePage: FC<{}> = () => {
{data.map((plugin: StorePlugin) => (
<PluginCard plugin={plugin} />
))}
{!legacyData ? (
<SteamSpinner />
) : (
legacyData.map((plugin: LegacyStorePlugin) => <PluginCard plugin={plugin} />)
)}
</div>
)}
</div>
+8
View File
@@ -1,6 +1,9 @@
import {
ReactRouter,
Router,
fakeRenderComponent,
findInReactTree,
findInTree,
findModule,
findModuleChild,
gamepadDialogClasses,
@@ -71,6 +74,11 @@ export async function startup() {
window.DFL = {
findModuleChild,
findModule,
ReactUtils: {
fakeRenderComponent,
findInReactTree,
findInTree,
},
Router,
ReactRouter,
classes: {
+35 -10
View File
@@ -8,8 +8,8 @@ import {
sleep,
staticClasses,
} from 'decky-frontend-lib';
import { lazy } from 'react';
import { FaPlug } from 'react-icons/fa';
import { FC, lazy } from 'react';
import { FaCog, FaExclamationCircle, FaPlug } from 'react-icons/fa';
import { DeckyState, DeckyStateContextProvider, useDeckyState } from './components/DeckyState';
import LegacyPlugin from './components/LegacyPlugin';
@@ -23,6 +23,7 @@ import { Plugin } from './plugin';
import RouterHook from './router-hook';
import { checkForUpdates } from './store';
import TabsHook from './tabs-hook';
import OldTabsHook from './tabs-hook.old';
import Toaster from './toaster';
import { VerInfo, callUpdaterMethod } from './updater';
import { getSetting } from './utils/settings';
@@ -38,7 +39,7 @@ declare global {
class PluginLoader extends Logger {
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 routerHook: RouterHook = new RouterHook();
public toaster: Toaster = new Toaster();
@@ -52,6 +53,7 @@ class PluginLoader extends Logger {
constructor() {
super(PluginLoader.name);
this.tabsHook.init();
this.log('Initialized');
const TabBadge = () => {
@@ -233,13 +235,36 @@ class PluginLoader extends Logger {
},
});
if (res.ok) {
let plugin_export = await eval(await res.text());
let plugin = plugin_export(this.createPluginAPI(name));
this.plugins.push({
...plugin,
name: name,
version: version,
});
try {
let plugin_export = await eval(await res.text());
let plugin = plugin_export(this.createPluginAPI(name));
this.plugins.push({
...plugin,
name: name,
version: version,
});
} catch (e) {
this.error('Error loading plugin ' + name, e);
const TheError: FC<{}> = () => (
<>
Error:{' '}
<pre>
<code>{e instanceof Error ? e.stack : JSON.stringify(e)}</code>
</pre>
<>
Please go to <FaCog style={{ display: 'inline' }} /> in the Decky menu if you need to uninstall this
plugin.
</>
</>
);
this.plugins.push({
name: name,
version: version,
content: <TheError />,
icon: <FaExclamationCircle />,
});
this.toaster.toast({ title: 'Error loading ' + name, body: '' + e, icon: <FaExclamationCircle /> });
}
} else throw new Error(`${name} frontend_bundle not OK`);
}
+67 -22
View File
@@ -1,7 +1,12 @@
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 {
DeckyGlobalComponentsState,
DeckyGlobalComponentsStateContextProvider,
useDeckyGlobalComponentsState,
} from './components/DeckyGlobalComponentsState';
import {
DeckyRouterState,
DeckyRouterStateContextProvider,
@@ -22,8 +27,10 @@ class RouterHook extends Logger {
private memoizedRouter: any;
private gamepadWrapper: any;
private routerState: DeckyRouterState = new DeckyRouterState();
private globalComponentsState: DeckyGlobalComponentsState = new DeckyGlobalComponentsState();
private wrapperPatch: Patch;
private routerPatch?: Patch;
public routes?: any[];
constructor() {
super('RouterHook');
@@ -42,24 +49,28 @@ class RouterHook extends Logger {
let Route: new () => Route;
// Used to store the new replicated routes we create to allow routes to be unpatched.
let toReplace = new Map<string, ReactNode>();
const DeckyWrapper = ({ children }: { children: ReactElement }) => {
const { routes, routePatches } = useDeckyRouterState();
const routeList = children.props.children[0].props.children;
const processList = (
routeList: any[],
routes: Map<string, RouterEntry> | null,
routePatches: Map<string, Set<RoutePatch>>,
save: boolean,
) => {
this.debug('Route list: ', routeList);
if (save) this.routes = routeList;
let routerIndex = routeList.length;
if (!routeList[routerIndex - 1]?.length || routeList[routerIndex - 1]?.length !== routes.size) {
if (routeList[routerIndex - 1]?.length && routeList[routerIndex - 1].length !== routes.size) routerIndex--;
const newRouterArray: ReactElement[] = [];
routes.forEach(({ component, props }, path) => {
newRouterArray.push(
<Route path={path} {...props}>
{createElement(component)}
</Route>,
);
});
routeList[routerIndex] = newRouterArray;
if (routes) {
if (!routeList[routerIndex - 1]?.length || routeList[routerIndex - 1]?.length !== routes.size) {
if (routeList[routerIndex - 1]?.length && routeList[routerIndex - 1].length !== routes.size) routerIndex--;
const newRouterArray: ReactElement[] = [];
routes.forEach(({ component, props }, path) => {
newRouterArray.push(
<Route path={path} {...props}>
{createElement(component)}
</Route>,
);
});
routeList[routerIndex] = newRouterArray;
}
}
routeList.forEach((route: Route, index: number) => {
const replaced = toReplace.get(route?.props?.path as string);
@@ -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');
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) => {
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 (
ret.props.children.props.children[2]?.props?.children?.[0]?.type?.type
ret.props.children.props.children[idx]?.props?.children?.[0]?.type?.type
?.toString()
?.includes('GamepadUI.Settings.Root()')
) {
if (!this.router) {
this.router = ret.props.children.props.children[2]?.props?.children?.[0]?.type;
this.router = ret.props.children.props.children[idx]?.props?.children?.[0]?.type;
this.routerPatch = afterPatch(this.router, 'type', (_: any, ret: any) => {
if (!Route)
Route = ret.props.children[0].props.children.find((x: any) => x.props.path == '/createaccount').type;
@@ -111,7 +143,12 @@ class RouterHook extends Logger {
this.memoizedRouter = memo(this.router.type);
this.memoizedRouter.isDeckyRouter = true;
}
ret.props.children.props.children[2].props.children[0].type = this.memoizedRouter;
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;
@@ -126,6 +163,14 @@ class RouterHook extends Logger {
return this.routerState.addPatch(path, patch);
}
addGlobalComponent(name: string, component: FC) {
this.globalComponentsState.addComponent(name, component);
}
removeGlobalComponent(name: string) {
this.globalComponentsState.removeComponent(name);
}
removePatch(path: string, patch: RoutePatch) {
this.routerState.removePatch(path, patch);
}
+45 -51
View File
@@ -1,6 +1,11 @@
import { ConfirmModal, showModal, staticClasses } from 'decky-frontend-lib';
import { Plugin } from './plugin';
import { getSetting, setSetting } from './utils/settings';
export enum Store {
Default,
Testing,
Custom,
}
export interface StorePluginVersion {
name: string;
@@ -14,31 +19,49 @@ export interface StorePlugin {
author: string;
description: string;
tags: string[];
}
export interface LegacyStorePlugin {
artifact: string;
versions: {
[version: string]: string;
};
author: string;
description: string;
tags: string[];
image_url: string;
}
// name: version
export type PluginUpdateMapping = Map<string, StorePluginVersion>;
export function getPluginList(): Promise<StorePlugin[]> {
return fetch('https://beta.deckbrew.xyz/plugins', {
method: 'GET',
}).then((r) => r.json());
}
export function getLegacyPluginList(): Promise<LegacyStorePlugin[]> {
return fetch('https://plugins.deckbrew.xyz/get_plugins', {
method: 'GET',
}).then((r) => r.json());
export async function getPluginList(): Promise<StorePlugin[]> {
let version = await window.DeckyPluginLoader.updateVersion();
let store = await getSetting<Store>('store', Store.Default);
let customURL = await getSetting<string>('store-url', 'https://plugins.deckbrew.xyz/plugins');
let storeURL;
if (!store) {
console.log('Could not get a default store, using Default.');
await setSetting('store-url', Store.Default);
return fetch('https://plugins.deckbrew.xyz/plugins', {
method: 'GET',
headers: {
'X-Decky-Version': version.current,
},
}).then((r) => r.json());
} else {
switch (+store) {
case Store.Default:
storeURL = 'https://plugins.deckbrew.xyz/plugins';
break;
case Store.Testing:
storeURL = 'https://testing.deckbrew.xyz/plugins';
break;
case Store.Custom:
storeURL = customURL;
break;
default:
console.error('Somehow you ended up without a standard URL, using the default URL.');
storeURL = 'https://plugins.deckbrew.xyz/plugins';
break;
}
return fetch(storeURL, {
method: 'GET',
headers: {
'X-Decky-Version': version.current,
},
}).then((r) => r.json());
}
}
export async function installFromURL(url: string) {
@@ -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) {
await window.DeckyPluginLoader.callServerMethod('install_plugin', {
name: plugin,
@@ -94,7 +92,3 @@ export async function checkForUpdates(plugins: Plugin[]): Promise<PluginUpdateMa
}
return updateMap;
}
export function isLegacyPlugin(plugin: LegacyStorePlugin | StorePlugin): plugin is LegacyStorePlugin {
return 'artifact' in plugin;
}
+119
View File
@@ -0,0 +1,119 @@
// TabsHook for versions before the Desktop merge
import { Patch, afterPatch, sleep } from 'decky-frontend-lib';
import { memo } from 'react';
import NewTabsHook from './tabs-hook';
declare global {
interface Array<T> {
__filter: any;
}
}
const isTabsArray = (tabs: any) => {
const length = tabs.length;
return length >= 7 && tabs[length - 1]?.tab;
};
class TabsHook extends NewTabsHook {
// private keys = 7;
private quickAccess: any;
private tabRenderer: any;
private memoizedQuickAccess: any;
private cNode: any;
private qAPTree: any;
private rendererTree: any;
private cNodePatch?: Patch;
constructor() {
super();
this.log('Initialized stable TabsHook');
}
init() {
const self = this;
const tree = (document.getElementById('root') as any)._reactRootContainer._internalRoot.current;
let scrollRoot: any;
async function findScrollRoot(currentNode: any, iters: number): Promise<any> {
if (iters >= 30) {
self.error(
'Scroll root was not found before hitting the recursion limit, a developer will need to increase the limit.',
);
return null;
}
currentNode = currentNode?.child;
if (currentNode?.type?.prototype?.RemoveSmartScrollContainer) {
self.log(`Scroll root was found in ${iters} recursion cycles`);
return currentNode;
}
if (!currentNode) return null;
if (currentNode.sibling) {
let node = await findScrollRoot(currentNode.sibling, iters + 1);
if (node !== null) return node;
}
return await findScrollRoot(currentNode, iters + 1);
}
(async () => {
scrollRoot = await findScrollRoot(tree, 0);
while (!scrollRoot) {
this.log('Failed to find scroll root node, reattempting in 5 seconds');
await sleep(5000);
scrollRoot = await findScrollRoot(tree, 0);
}
let newQA: any;
let newQATabRenderer: any;
this.cNodePatch = afterPatch(scrollRoot.stateNode, 'render', (_: any, ret: any) => {
if (!this.quickAccess && ret.props.children.props.children[4]) {
this.quickAccess = ret?.props?.children?.props?.children[4].type;
newQA = (...args: any) => {
const ret = this.quickAccess.type(...args);
if (ret) {
if (!newQATabRenderer) {
this.tabRenderer = ret.props.children[1].children.type;
newQATabRenderer = (...qamArgs: any[]) => {
const oFilter = Array.prototype.filter;
Array.prototype.filter = function (...args: any[]) {
if (isTabsArray(this)) {
self.render(this, qamArgs[0].visible);
}
// @ts-ignore
return oFilter.call(this, ...args);
};
// TODO remove array hack entirely and use this instead const tabs = ret.props.children.props.children[0].props.children[1].props.children[0].props.children[0].props.tabs
const ret = this.tabRenderer(...qamArgs);
Array.prototype.filter = oFilter;
return ret;
};
}
this.rendererTree = ret.props.children[1].children;
ret.props.children[1].children.type = newQATabRenderer;
}
return ret;
};
this.memoizedQuickAccess = memo(newQA);
this.memoizedQuickAccess.isDeckyQuickAccess = true;
}
if (ret.props.children.props.children[4]) {
this.qAPTree = ret.props.children.props.children[4];
ret.props.children.props.children[4].type = this.memoizedQuickAccess;
}
return ret;
});
this.cNode = scrollRoot;
this.cNode.stateNode.forceUpdate();
this.log('Finished initial injection');
})();
}
deinit() {
this.cNodePatch?.unpatch();
if (this.qAPTree) this.qAPTree.type = this.quickAccess;
if (this.rendererTree) this.rendererTree.type = this.tabRenderer;
if (this.cNode) this.cNode.stateNode.forceUpdate();
}
}
export default TabsHook;
+85 -82
View File
@@ -1,5 +1,5 @@
import { Patch, QuickAccessTab, afterPatch, sleep } from 'decky-frontend-lib';
import { memo } from 'react';
// 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';
@@ -7,17 +7,10 @@ import Logger from './logger';
declare global {
interface Window {
__TABS_HOOK_INSTANCE: any;
}
interface Array<T> {
__filter: any;
securitystore: any;
}
}
const isTabsArray = (tabs: any) => {
const length = tabs.length;
return length >= 7 && tabs[length - 1]?.tab;
};
interface Tab {
id: QuickAccessTab | number;
title: any;
@@ -28,15 +21,9 @@ interface Tab {
class TabsHook extends Logger {
// private keys = 7;
tabs: Tab[] = [];
private quickAccess: any;
private tabRenderer: any;
private memoizedQuickAccess: any;
private cNode: any;
private qAPTree: any;
private rendererTree: any;
private cNodePatch?: Patch;
private qAMRoot?: any;
private qamPatch?: Patch;
private unsubscribeSecurity?: () => void;
constructor() {
super('TabsHook');
@@ -44,86 +31,90 @@ class TabsHook extends Logger {
this.log('Initialized');
window.__TABS_HOOK_INSTANCE?.deinit?.();
window.__TABS_HOOK_INSTANCE = this;
}
const self = this;
init() {
const tree = (document.getElementById('root') as any)._reactRootContainer._internalRoot.current;
let scrollRoot: any;
async function findScrollRoot(currentNode: any, iters: number): Promise<any> {
if (iters >= 30) {
self.error(
'Scroll root was not found before hitting the recursion limit, a developer will need to increase the limit.',
);
let qAMRoot: any;
const findQAMRoot = (currentNode: any, iters: number): any => {
if (iters >= 55) {
// currently 45
return null;
}
currentNode = currentNode?.child;
if (currentNode?.type?.prototype?.RemoveSmartScrollContainer) {
self.log(`Scroll root was found in ${iters} recursion cycles`);
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) return null;
if (currentNode.sibling) {
let node = await findScrollRoot(currentNode.sibling, iters + 1);
if (currentNode.child) {
let node = findQAMRoot(currentNode.child, iters + 1);
if (node !== null) return node;
}
return await findScrollRoot(currentNode, iters + 1);
}
(async () => {
scrollRoot = await findScrollRoot(tree, 0);
while (!scrollRoot) {
this.log('Failed to find scroll root node, reattempting in 5 seconds');
await sleep(5000);
scrollRoot = await findScrollRoot(tree, 0);
if (currentNode.sibling) {
let node = findQAMRoot(currentNode.sibling, iters + 1);
if (node !== null) return node;
}
let newQA: any;
let newQATabRenderer: any;
this.cNodePatch = afterPatch(scrollRoot.stateNode, 'render', (_: any, ret: any) => {
if (!this.quickAccess && ret.props.children.props.children[4]) {
this.quickAccess = ret?.props?.children?.props?.children[4].type;
newQA = (...args: any) => {
const ret = this.quickAccess.type(...args);
if (ret) {
if (!newQATabRenderer) {
this.tabRenderer = ret.props.children[1].children.type;
newQATabRenderer = (...qamArgs: any[]) => {
const oFilter = Array.prototype.filter;
Array.prototype.filter = function (...args: any[]) {
if (isTabsArray(this)) {
self.render(this, qamArgs[0].visible);
}
// @ts-ignore
return oFilter.call(this, ...args);
};
// TODO remove array hack entirely and use this instead const tabs = ret.props.children.props.children[0].props.children[1].props.children[0].props.children[0].props.tabs
const ret = this.tabRenderer(...qamArgs);
Array.prototype.filter = oFilter;
return ret;
};
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);
}
this.rendererTree = ret.props.children[1].children;
ret.props.children[1].children.type = newQATabRenderer;
}
return ret;
};
this.memoizedQuickAccess = memo(newQA);
this.memoizedQuickAccess.isDeckyQuickAccess = true;
}
if (ret.props.children.props.children[4]) {
this.qAPTree = ret.props.children.props.children[4];
ret.props.children.props.children[4].type = this.memoizedQuickAccess;
return ret;
});
qAMRoot.child.type.decky = true;
qAMRoot.child.alternate.type = qAMRoot.child.type;
}
} catch (e) {
this.error('Error patching QAM', e);
}
return ret;
});
this.cNode = scrollRoot;
this.cNode.stateNode.forceUpdate();
if (qAMRoot.return.alternate) {
qAMRoot.return.alternate.type = qAMRoot.return.type;
}
this.log('Finished initial injection');
})();
}
deinit() {
this.cNodePatch?.unpatch();
if (this.qAPTree) this.qAPTree.type = this.quickAccess;
if (this.rendererTree) this.rendererTree.type = this.tabRenderer;
if (this.cNode) this.cNode.stateNode.forceUpdate();
this.qamPatch?.unpatch();
this.qAMRoot.return.alternate.type = this.qAMRoot.return.type;
this.unsubscribeSecurity?.();
}
add(tab: Tab) {
@@ -137,12 +128,24 @@ class TabsHook extends Logger {
}
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,
panel: <QuickAccessVisibleStateProvider visible={visible}>{content}</QuickAccessVisibleStateProvider>,
decky: true,
panel: (
<QuickAccessVisibleStateProvider initial={visible} setter={[]}>
{content}
</QuickAccessVisibleStateProvider>
),
});
}
}
+101 -47
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 Toast from './components/Toast';
@@ -12,13 +12,18 @@ declare global {
}
class Toaster extends Logger {
private instanceRetPatch?: Patch;
// private routerHook: RouterHook;
// private toasterState: DeckyToasterState = new DeckyToasterState();
private node: any;
private rNode: 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() {
super('Toaster');
// this.routerHook = routerHook;
window.__TOASTER_INSTANCE?.deinit?.();
window.__TOASTER_INSTANCE = this;
@@ -26,69 +31,116 @@ class Toaster extends Logger {
}
async init() {
// this.routerHook.addGlobalComponent('DeckyToaster', () => (
// <DeckyToasterStateContextProvider deckyToasterState={this.toasterState}>
// <DeckyToaster />
// </DeckyToasterStateContextProvider>
// ));
let instance: any;
while (true) {
instance = findInReactTree(
(document.getElementById('root') as any)._reactRootContainer._internalRoot.current,
(x) => x?.memoizedProps?.className?.startsWith?.('toastmanager_ToastPlaceholder'),
const tree = (document.getElementById('root') as any)._reactRootContainer._internalRoot.current;
const findToasterRoot = (currentNode: any, iters: number): any => {
if (iters >= 50) {
// currently 40
return null;
}
if (currentNode?.memoizedProps?.className?.startsWith?.('toastmanager_ToastPlaceholder')) {
this.log(`Toaster root was found in ${iters} recursion cycles`);
return currentNode;
}
if (currentNode.sibling) {
let node = findToasterRoot(currentNode.sibling, iters + 1);
if (node !== null) return node;
}
if (currentNode.child) {
let node = findToasterRoot(currentNode.child, iters + 1);
if (node !== null) return node;
}
return null;
};
instance = findToasterRoot(tree, 0);
while (!instance) {
this.error(
'Failed to find Toaster root node, reattempting in 5 seconds. A developer may need to increase the recursion limit.',
);
if (instance) break;
this.debug('finding instance');
await sleep(2000);
await sleep(5000);
instance = findToasterRoot(tree, 0);
}
this.node = instance.return.return;
this.node = instance.return;
this.rNode = this.node.return;
let toast: any;
let renderedToast: ReactNode = null;
this.node.stateNode.render = (...args: any[]) => {
const ret = this.node.stateNode.__proto__.render.call(this.node.stateNode, ...args);
if (ret) {
this.instanceRetPatch = afterPatch(ret, 'type', (_: any, ret: any) => {
if (ret?.props?.children[1]?.children?.props) {
const currentToast = ret.props.children[1].children.props.notification;
if (currentToast?.decky) {
if (currentToast == toast) {
ret.props.children[1].children = renderedToast;
let innerPatched: any;
const repatch = () => {
if (this.node && !this.node.type.decky) {
this.toasterPatch = afterPatch(this.node, 'type', (_: any, ret: any) => {
const inner = findInReactTree(ret.props.children, (x) => x?.props?.onDismiss);
if (innerPatched) {
inner.type = innerPatched;
} else {
afterPatch(inner, 'type', (innerArgs: any, ret: any) => {
const currentToast = innerArgs[0]?.notification;
if (currentToast?.decky) {
if (currentToast == toast) {
ret.props.children = renderedToast;
} else {
toast = currentToast;
renderedToast = <Toast toast={toast.data} />;
ret.props.children = renderedToast;
}
} else {
toast = currentToast;
renderedToast = <Toast toast={toast} />;
ret.props.children[1].children = renderedToast;
toast = null;
renderedToast = null;
}
} else {
toast = null;
renderedToast = null;
}
return ret;
});
innerPatched = inner.type;
}
return ret;
});
this.node.stateNode.shouldComponentUpdate = () => {
return false;
};
delete this.node.stateNode.render;
this.node.type.decky = true;
this.node.alternate.type = this.node.type;
}
};
const oRender = this.rNode.stateNode.__proto__.render;
let int: NodeJS.Timer | undefined;
this.rNode.stateNode.render = (...args: any[]) => {
const ret = oRender.call(this.rNode.stateNode, ...args);
if (ret && !this?.node?.return?.return) {
clearInterval(int);
int = setInterval(() => {
const n = findToasterRoot(tree, 0);
if (n?.return) {
clearInterval(int);
this.node = n.return;
this.rNode = this.node.return;
repatch();
} else {
this.error('Failed to re-grab Toaster node, trying again...');
}
}, 1200);
}
repatch();
return ret;
};
this.settingsModule = findModuleChild((m) => {
if (typeof m !== 'object') return undefined;
for (let prop in m) {
if (typeof m[prop]?.settings && m[prop]?.communityPreferences) return m[prop];
}
});
this.rNode.stateNode.shouldComponentUpdate = () => true;
this.rNode.stateNode.forceUpdate();
delete this.rNode.stateNode.shouldComponentUpdate;
this.log('Initialized');
this.ready = true;
this.finishStartup?.();
}
async toast(toast: ToastData) {
while (!this.ready) {
await sleep(100);
}
// toast.duration = toast.duration || 5e3;
// this.toasterState.addToast(toast);
await this.ready;
const settings = this.settingsModule?.settings;
let toastData = {
nNotificationID: window.NotificationStore.m_nNextTestNotificationID++,
rtCreated: Date.now(),
eType: 15,
nToastDurationMS: toast.duration || 5e3,
nToastDurationMS: toast.duration || (toast.duration = 5e3),
data: toast,
decky: true,
};
@@ -104,9 +156,11 @@ class Toaster extends Logger {
}
deinit() {
this.instanceRetPatch?.unpatch();
this.node && delete this.node.stateNode.shouldComponentUpdate;
this.node && this.node.stateNode.forceUpdate();
this.toasterPatch?.unpatch();
this.node.alternate.type = this.node.type;
delete this.rNode.stateNode.render;
this.ready = new Promise((res) => (this.finishStartup = res));
// this.routerHook.removeGlobalComponent('DeckyToaster');
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
export enum Branches {
Release,
Prerelease,
Nightly,
// Testing,
}
export interface DeckyUpdater {
+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;
}