Compare commits

...

8 Commits

Author SHA1 Message Date
AAGaming 1265672067 Update @decky/ui to fix non-global dfl plugins crashing the UI due to a race condition 2024-08-08 14:25:56 -04:00
Sims b8acc849bc Ensure boot after network.target (#670) 2024-08-07 16:19:37 -04:00
AAGaming 65b6883dcc handle crashloops and disable decky for the user 2024-08-07 16:14:18 -04:00
AAGaming 166c7ea8a7 Work around account switching failing to open the CEF debugger socket (#668)
* Work around account switching failing to open the CEF debugger socket

this automates lsof and gdb to force close the socket before steam finishes shutting down (from RegisterForShutdownStart)

* lint

* fix LD_LIBRARY_PATH for gdb
2024-08-06 20:25:39 -07:00
WerWolvTranslationBot ddc807340c Translated using Weblate (Japanese) (#669)
Currently translated at 100.0% (158 of 158 strings)

Translation: Decky/Decky
Translate-URL: https://weblate.werwolv.net/projects/decky/decky/ja/

Co-authored-by: Tak-attack <tak.bts@gmail.com>
2024-08-06 08:00:19 +00:00
AAGaming 7fc51c8b7d shut down properly when using runpydeck 2024-08-05 16:47:13 -04:00
AAGaming 131f0961ff Rewrite router/tabs/toaster hooks (#661) 2024-08-05 14:07:10 -04:00
Sims 75aa1e4851 Sims/pyinstaller misuse fix (#657)
* Fix misuse of pyinstaller

* Fix breaking change

* Fix pywright import errors
2024-08-05 00:00:49 +02:00
32 changed files with 983 additions and 640 deletions
+1 -1
View File
@@ -120,7 +120,7 @@
"dependsOn": [
"checkforsettings"
],
"command": "ssh ${config:deckuser}@${config:deckip} -p ${config:deckport} ${config:deckkey} 'export PATH=${config:deckdir}/.local/bin:$PATH; export PLUGIN_PATH=${config:deckdir}/homebrew/plugins; export CHOWN_PLUGIN_PATH=0; export LOG_LEVEL=DEBUG; cd ${config:deckdir}/homebrew/dev/pluginloader/backend; echo '${config:deckpass}' | poetry run sh -c \"cd ${config:deckdir}/homebrew/services; sudo -SE env \"PATH=\\$PATH\" python3 ${config:deckdir}/homebrew/dev/pluginloader/backend/main.py\"'",
"command": "ssh -t ${config:deckuser}@${config:deckip} -p ${config:deckport} ${config:deckkey} 'export PATH=${config:deckdir}/.local/bin:$PATH; export PLUGIN_PATH=${config:deckdir}/homebrew/plugins; export CHOWN_PLUGIN_PATH=0; export LOG_LEVEL=DEBUG; cd ${config:deckdir}/homebrew/dev/pluginloader/backend; echo '${config:deckpass}' | poetry run sh -c \"cd ${config:deckdir}/homebrew/services; sudo -SE env \"PATH=\\$PATH\" python3 ${config:deckdir}/homebrew/dev/pluginloader/backend/main.py\"'",
"problemMatcher": []
},
{
+8 -1
View File
@@ -52,6 +52,9 @@ async def csrf_middleware(request: Request, handler: Handler):
return await handler(request)
return Response(text='Forbidden', status=403)
def create_inject_script(script: str) -> str:
return "try{if (window.deckyHasLoaded){setTimeout(() => SteamClient.Browser.RestartJSContext(), 100)}else{window.deckyHasLoaded = true;(async()=>{try{await import('http://localhost:1337/frontend/%s?v=%s')}catch(e){console.error(e)};})();}}catch(e){console.error(e)}" % (script, get_loader_version(), )
# Get the default homebrew path unless a home_path is specified. home_path argument is deprecated
def get_homebrew_path() -> str:
return localplatform.get_unprivileged_path()
@@ -93,7 +96,11 @@ def get_system_pythonpaths() -> list[str]:
proc = subprocess.run(["python3" if localplatform.ON_LINUX else "python", "-c", "import sys; print('\\n'.join(x for x in sys.path if x))"],
# TODO make this less insane
capture_output=True, user=localplatform.localplatform._get_user_id() if localplatform.ON_LINUX else None, env={} if localplatform.ON_LINUX else None) # pyright: ignore [reportPrivateUsage]
return [x.strip() for x in proc.stdout.decode().strip().split("\n")]
proc.check_returncode()
versions = [x.strip() for x in proc.stdout.decode().strip().split("\n")]
return [x for x in versions if x and not x.isspace()]
except Exception as e:
logger.warn(f"Failed to execute get_system_pythonpaths(): {str(e)}")
return []
+1 -1
View File
@@ -415,7 +415,7 @@ CLOSEABLE_URLS = ["about:blank", "data:text/html,%3Cbody%3E%3C%2Fbody%3E"] # Clo
DO_NOT_CLOSE_URLS = ["Valve Steam Gamepad/default", "Valve%20Steam%20Gamepad"] # Steam Big Picture Mode tab
def tab_is_gamepadui(t: Tab) -> bool:
return "https://steamloopback.host/routes/" in t.url and t.title in SHARED_CTX_NAMES
return ("https://steamloopback.host/routes/" in t.url or "https://steamloopback.host/index.html" in t.url) and t.title in SHARED_CTX_NAMES
async def get_gamepadui_tab() -> Tab:
tabs = await get_tabs()
+24 -1
View File
@@ -95,12 +95,14 @@
}
},
"PluginListIndex": {
"freeze": "アップデートを凍結",
"hide": "クイックアクセス: 非表示",
"no_plugin": "プラグインがインストールされていません!",
"plugin_actions": "プラグインアクション",
"reinstall": "再インストール",
"reload": "再読み込み",
"show": "クイックアクセス: 表示",
"unfreeze": "アップデートを許可",
"uninstall": "アンインストール",
"update_all_other": "{{count}} 個のプラグインをアップデート",
"update_to": "{{name}} を更新"
@@ -185,9 +187,19 @@
"SettingsIndex": {
"developer_title": "開発者",
"general_title": "一般",
"plugins_title": "プラグイン"
"plugins_title": "プラグイン",
"testing_title": "テスト"
},
"Store": {
"download_progress_info": {
"download_zip": "プラグインのダウンロード中",
"increment_count": "ダウンロード数の増加",
"installing_plugin": "プラグインのインストール中",
"open_zip": "zipファイルを展開中",
"parse_zip": "zipファイルの解析中",
"start": "初期化中",
"uninstalling_previous": "以前のコピーのアンインストール"
},
"store_contrib": {
"desc": "Decky Plugin Storeに貢献したい場合は、GitHubのSteamDeckHomebrew/decky-plugin-templateリポジトリを確認してください。 開発と配布に関する情報は README で入手できます。",
"label": "貢献"
@@ -211,6 +223,10 @@
"about": "概要",
"alph_asce": "アルファベット(Z to A)",
"alph_desc": "アルファベット(A to Z)",
"date_asce": "古い順",
"date_desc": "新しい順",
"downloads_asce": "ダウンロード数が少ない順",
"downloads_desc": "ダウンロード数が多い順",
"title": "閲覧"
},
"store_testing_cta": "Decky Loaderチームを支援するために、新しいプラグインのテストを検討してください!",
@@ -231,6 +247,13 @@
"testing": "テスト"
}
},
"Testing": {
"download": "ダウンロード",
"error": "PRのインストールエラー",
"header": "Decky Loaderの以下のバージョンは、公開されているサードパーティのPull Requestからビルドされたものです。 Decky Loaderチームはその機能や安全性を検証しておらず、内容も古い可能性があります。",
"loading": "Pull Requestの読み込み中...",
"start_download_toast": "PR #{{id}}のダウンロード中"
},
"TitleView": {
"decky_store_desc": "Deckyストアを開く",
"settings_desc": "Decky設定を開く"
@@ -37,6 +37,9 @@ def get_live_reload() -> bool:
def get_keep_systemd_service() -> bool:
return os.getenv("KEEP_SYSTEMD_SERVICE", "0") == "1"
def get_use_cef_close_workaround() -> bool:
return ON_LINUX and os.getenv("USE_CEF_CLOSE_WORKAROUND", "1") == "1"
def get_log_level() -> int:
return {"CRITICAL": 50, "ERROR": 40, "WARNING": 30, "INFO": 20, "DEBUG": 10}[
os.getenv("LOG_LEVEL", "INFO")
@@ -1,3 +1,5 @@
from re import compile
from asyncio import Lock
import os, pwd, grp, sys, logging
from subprocess import call, run, DEVNULL, PIPE, STDOUT
from ..enums import UserType
@@ -227,3 +229,39 @@ def get_unprivileged_user() -> str:
user = 'deck'
return user
# Works around the CEF debugger TCP socket not closing properly when Steam restarts
# Group 1 is PID, group 2 is FD. this also filters for "steamwebhelper" in the process name.
cef_socket_lsof_regex = compile(r"^p(\d+)(?:\s|.)+csteamwebhelper(?:\s|.)+f(\d+)(?:\s|.)+TST=LISTEN")
close_cef_socket_lock = Lock()
async def close_cef_socket():
async with close_cef_socket_lock:
if _get_effective_user_id() != 0:
logger.warn("Can't close CEF socket as Decky isn't running as root.")
return
# Look for anything listening TCP on port 8080
lsof = run(["lsof", "-F", "-iTCP:8080", "-sTCP:LISTEN"], capture_output=True, text=True)
if lsof.returncode != 0 or len(lsof.stdout) < 1:
logger.error(f"lsof call failed in close_cef_socket! return code: {str(lsof.returncode)}")
return
lsof_data = cef_socket_lsof_regex.match(lsof.stdout)
if not lsof_data:
logger.error("lsof regex match failed in close_cef_socket!")
return
pid = lsof_data.group(1)
fd = lsof_data.group(2)
logger.info(f"Closing CEF socket with PID {pid} and FD {fd}")
# Use gdb to inject a close() call for the socket fd into steamwebhelper
gdb_ret = run(["gdb", "--nx", "-p", pid, "--batch", "--eval-command", f"call (int)close({fd})"], env={"LD_LIBRARY_PATH": ""})
if gdb_ret.returncode != 0:
logger.error(f"Failed to close CEF socket with gdb! return code: {str(gdb_ret.returncode)}", exc_info=True)
return
logger.info("CEF socket closed")
@@ -55,4 +55,7 @@ def get_unprivileged_user() -> str:
return os.getenv("UNPRIVILEGED_USER", os.getlogin())
async def restart_webhelper() -> bool:
return True # Stubbed
return True # Stubbed
async def close_cef_socket():
return # Stubbed
+32 -7
View File
@@ -7,14 +7,17 @@ from .localplatform.localplatform import (chmod, chown, service_stop, service_st
get_privileged_path, restart_webhelper)
if hasattr(sys, '_MEIPASS'):
chmod(sys._MEIPASS, 755) # type: ignore
# Full imports
import multiprocessing
multiprocessing.freeze_support()
from asyncio import AbstractEventLoop, CancelledError, Task, all_tasks, current_task, gather, new_event_loop, set_event_loop, sleep
from logging import basicConfig, getLogger
from os import path
from traceback import format_exc
import multiprocessing
from time import time
import aiohttp_cors # pyright: ignore [reportMissingTypeStubs]
# Partial imports
from aiohttp import client_exceptions
from aiohttp.web import Application, Response, Request, get, run_app, static # pyright: ignore [reportUnknownVariableType]
@@ -23,7 +26,7 @@ from setproctitle import getproctitle, setproctitle, setthreadtitle
# local modules
from .browser import PluginBrowser
from .helpers import (REMOTE_DEBUGGER_UNIT, csrf_middleware, get_csrf_token, get_loader_version,
from .helpers import (REMOTE_DEBUGGER_UNIT, create_inject_script, csrf_middleware, get_csrf_token, get_loader_version,
mkdir_as_user, get_system_pythonpaths, get_effective_user_id)
from .injector import get_gamepadui_tab, Tab
@@ -73,6 +76,9 @@ class PluginManager:
self.plugin_browser = PluginBrowser(plugin_path, self.plugin_loader.plugins, self.plugin_loader, self.settings)
self.utilities = Utilities(self)
self.updater = Updater(self)
self.last_webhelper_exit: float = 0
self.webhelper_crash_count: int = 0
self.inject_fallback: bool = False
jinja_setup(self.web_app)
@@ -94,6 +100,21 @@ class PluginManager:
self.cors.add(route) # pyright: ignore [reportUnknownMemberType]
self.web_app.add_routes([static("/static", path.join(path.dirname(__file__), 'static'))])
async def handle_crash(self):
new_time = time()
if (new_time - self.last_webhelper_exit < 60):
self.webhelper_crash_count += 1
logger.warn(f"webhelper crashed within a minute from last crash! crash count: {self.webhelper_crash_count}")
else:
self.webhelper_crash_count = 0
self.last_webhelper_exit = new_time
# should never happen
if (self.webhelper_crash_count > 4):
await self.updater.do_shutdown()
# Give up
exit(0)
async def shutdown(self, _: Application):
try:
logger.info(f"Shutting down...")
@@ -185,6 +206,7 @@ class PluginManager:
# 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...")
await self.handle_crash()
# At this point the loop starts again and we connect to the freshly started Steam client once it is ready.
except Exception:
if not self.reinject:
@@ -192,6 +214,7 @@ class PluginManager:
logger.error("Exception while reading page events " + format_exc())
await tab.close_websocket()
self.js_ctx_tab = None
await self.handle_crash()
pass
# while True:
# await sleep(5)
@@ -207,8 +230,13 @@ class PluginManager:
await tab.close_websocket()
self.js_ctx_tab = None
await restart_webhelper()
await sleep(1) # To give CEF enough time to close down the websocket
return # We'll catch the next tab in the main loop
await tab.evaluate_js("try{if (window.deckyHasLoaded){setTimeout(() => SteamClient.Browser.RestartJSContext(), 100)}else{window.deckyHasLoaded = true;(async()=>{try{await import('http://localhost:1337/frontend/index.js?v=%s')}catch(e){console.error(e)};})();}}catch(e){console.error(e)}" % (get_loader_version(), ), False, False, False)
await tab.evaluate_js(create_inject_script("index.js" if self.webhelper_crash_count < 3 else "fallback.js"), False, False, False)
if self.webhelper_crash_count > 2:
self.reinject = False
await sleep(1)
await self.updater.do_shutdown()
except:
logger.info("Failed to inject JavaScript into tab\n" + format_exc())
pass
@@ -223,9 +251,6 @@ def main():
# Fix windows/flask not recognising that .js means 'application/javascript'
import mimetypes
mimetypes.add_type('application/javascript', '.js')
# Required for multiprocessing support in frozen files
multiprocessing.freeze_support()
else:
if get_effective_user_id() != 0:
logger.warning(f"decky is running as an unprivileged user, this is not officially supported and may cause issues")
@@ -1,9 +1,9 @@
import sys
from os import path, environ
from signal import SIG_IGN, SIGINT, SIGTERM, getsignal, signal
from importlib.util import module_from_spec, spec_from_file_location
from json import dumps, loads
from logging import getLogger
from sys import exit, path as syspath, modules as sysmodules
from traceback import format_exc
from asyncio import (get_event_loop, new_event_loop,
set_event_loop)
@@ -13,7 +13,7 @@ from .messages import SocketResponseDict, SocketMessageType
from ..localplatform.localsocket import LocalSocket
from ..localplatform.localplatform import setgid, setuid, get_username, get_home_path
from ..enums import UserType
from .. import helpers
from .. import helpers, settings, injector # pyright: ignore [reportUnusedImport]
from typing import List, TypeVar, Any
@@ -78,12 +78,12 @@ class SandboxedPlugin:
environ["DECKY_PLUGIN_AUTHOR"] = self.author
# append the plugin's `py_modules` to the recognized python paths
syspath.append(path.join(environ["DECKY_PLUGIN_DIR"], "py_modules"))
sys.path.append(path.join(environ["DECKY_PLUGIN_DIR"], "py_modules"))
#TODO: FIX IN A LESS CURSED WAY
keys = [key for key in sysmodules if key.startswith("decky_loader.")]
keys = [key for key in sys.modules if key.startswith("decky_loader.")]
for key in keys:
sysmodules[key.replace("decky_loader.", "")] = sysmodules[key]
sys.modules[key.replace("decky_loader.", "")] = sys.modules[key]
from .imports import decky
async def emit(event: str, *args: Any) -> None:
@@ -95,9 +95,9 @@ class SandboxedPlugin:
# copy the docstring over so we don't have to duplicate it
emit.__doc__ = decky.emit.__doc__
decky.emit = emit
sysmodules["decky"] = decky
sys.modules["decky"] = decky
# provided for compatibility
sysmodules["decky_plugin"] = decky
sys.modules["decky_plugin"] = decky
spec = spec_from_file_location("_", self.file)
assert spec is not None
@@ -123,7 +123,7 @@ class SandboxedPlugin:
get_event_loop().create_task(socket.setup_server())
except:
self.log.error("Failed to start " + self.name + "!\n" + format_exc())
exit(0)
sys.exit(0)
try:
get_event_loop().run_forever()
except SystemExit:
@@ -180,7 +180,7 @@ class SandboxedPlugin:
loop = get_event_loop()
loop.call_soon_threadsafe(loop.stop)
exit(0)
sys.exit(0)
d: SocketResponseDict = {"type": SocketMessageType.RESPONSE, "res": None, "success": True, "id": data["id"]}
try:
+12 -4
View File
@@ -20,9 +20,8 @@ from .browser import PluginInstallRequest, PluginInstallType
if TYPE_CHECKING:
from .main import PluginManager
from .injector import inject_to_tab, get_gamepadui_tab, close_old_tabs, get_tab
from .localplatform.localplatform import ON_WINDOWS
from . import helpers
from .localplatform.localplatform import service_stop, service_start, get_home_path, get_username
from .localplatform.localplatform import ON_WINDOWS, service_stop, service_start, get_home_path, get_username, get_use_cef_close_workaround, close_cef_socket, restart_webhelper
class FilePickerObj(TypedDict):
file: Path
@@ -78,6 +77,8 @@ class Utilities:
context.ws.add_route("utilities/get_tab_id", self.get_tab_id)
context.ws.add_route("utilities/get_user_info", self.get_user_info)
context.ws.add_route("utilities/http_request", self.http_request_legacy)
context.ws.add_route("utilities/restart_webhelper", self.restart_webhelper)
context.ws.add_route("utilities/close_cef_socket", self.close_cef_socket)
context.ws.add_route("utilities/_call_legacy_utility", self._call_legacy_utility)
context.web_app.add_routes([
@@ -197,9 +198,9 @@ class Utilities:
self.logger.debug(f"Finished stream for {url}")
return res
async def http_request_legacy(self, method: str, url: str, extra_opts: Any = {}):
async def http_request_legacy(self, method: str, url: str, extra_opts: Any = {}, timeout: int | None = None):
async with ClientSession() as web:
res = await web.request(method, url, ssl=helpers.get_ssl_context(), **extra_opts)
res = await web.request(method, url, ssl=helpers.get_ssl_context(), timeout=timeout, **extra_opts)
text = await res.text()
return {
"status": res.status,
@@ -287,6 +288,13 @@ class Utilities:
await service_stop(helpers.SSHD_UNIT)
return True
async def close_cef_socket(self):
if get_use_cef_close_workaround():
await close_cef_socket()
async def restart_webhelper(self):
await restart_webhelper()
async def filepicker_ls(self,
path: str | None = None,
include_files: bool = True,
+1
View File
@@ -1,5 +1,6 @@
[Unit]
Description=SteamDeck Plugin Loader
After=network.target
[Service]
Type=simple
User=root
+1
View File
@@ -1,5 +1,6 @@
[Unit]
Description=SteamDeck Plugin Loader
After=network.target
[Service]
Type=simple
User=root
+2 -2
View File
@@ -13,7 +13,7 @@
"localize": "i18next"
},
"devDependencies": {
"@decky/api": "^1.1.0",
"@decky/api": "^1.1.1",
"@rollup/plugin-commonjs": "^26.0.1",
"@rollup/plugin-image": "^3.0.3",
"@rollup/plugin-json": "^6.1.0",
@@ -47,7 +47,7 @@
}
},
"dependencies": {
"@decky/ui": "^4.6.0",
"@decky/ui": "^4.7.1",
"filesize": "^10.1.2",
"i18next": "^23.11.5",
"i18next-http-backend": "^2.5.2",
+10 -10
View File
@@ -9,8 +9,8 @@ importers:
.:
dependencies:
'@decky/ui':
specifier: ^4.6.0
version: 4.6.0
specifier: ^4.7.1
version: 4.7.1
filesize:
specifier: ^10.1.2
version: 10.1.2
@@ -37,8 +37,8 @@ importers:
version: 4.0.0
devDependencies:
'@decky/api':
specifier: ^1.1.0
version: 1.1.0
specifier: ^1.1.1
version: 1.1.1
'@rollup/plugin-commonjs':
specifier: ^26.0.1
version: 26.0.1(rollup@4.18.0)
@@ -212,11 +212,11 @@ packages:
resolution: {integrity: sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q==}
engines: {node: '>=6.9.0'}
'@decky/api@1.1.0':
resolution: {integrity: sha512-ECCLeI+xj13b89931S/ww1pM3Hgo7utseiww8HXkITkl4OkRfGSO/jtm0srNZPZpkoNyD5k6raXBbDQ02zgAFg==}
'@decky/api@1.1.1':
resolution: {integrity: sha512-R5fkBRHBt5QIQY7Q0AlbVIhlIZ/nTzwBOoi8Rt4Go2fjFnoMKPInCJl6cPjXzimGwl2pyqKJgY6VnH6ar0XrHQ==}
'@decky/ui@4.6.0':
resolution: {integrity: sha512-hGofSF1VeBxZ6ewA1Fq9iAsg50hxSLcNSsSNWS6N9E5UzdeEhd/1/6PIExHbtnSnMQGJ3lk9FaBBaz6IbG0Mvg==}
'@decky/ui@4.7.1':
resolution: {integrity: sha512-yJwBgW+J2cMDfMkmcDFtzsubhUjekFZAtCnP55QEJ/1UKGR7sLNOvDLFYi1h5PI0K4L1XYcAMKHwbYFFTzcDTA==}
'@esbuild/aix-ppc64@0.20.2':
resolution: {integrity: sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==}
@@ -2287,9 +2287,9 @@ snapshots:
'@babel/helper-validator-identifier': 7.24.7
to-fast-properties: 2.0.0
'@decky/api@1.1.0': {}
'@decky/api@1.1.1': {}
'@decky/ui@4.6.0': {}
'@decky/ui@4.7.1': {}
'@esbuild/aix-ppc64@0.20.2':
optional: true
+57 -43
View File
@@ -11,48 +11,62 @@ import { visualizer } from 'rollup-plugin-visualizer';
const hiddenWarnings = ['THIS_IS_UNDEFINED', 'EVAL'];
export default defineConfig({
input: 'src/index.ts',
plugins: [
del({ targets: '../backend/decky_loader/static/*', force: true }),
commonjs(),
nodeResolve({
browser: true,
}),
externalGlobals({
react: 'SP_REACT',
'react-dom': 'SP_REACTDOM',
// hack to shut up react-markdown
process: '{cwd: () => {}}',
path: '{dirname: () => {}, join: () => {}, basename: () => {}, extname: () => {}}',
url: '{fileURLToPath: (f) => f}',
}),
typescript(),
json(),
replace({
preventAssignment: false,
'process.env.NODE_ENV': JSON.stringify('production'),
}),
image(),
visualizer(),
],
preserveEntrySignatures: false,
treeshake: {
// Assume all external modules have imports with side effects (the default) while allowing decky libraries to treeshake
pureExternalImports: true,
preset: 'smallest'
},
output: {
dir: '../backend/decky_loader/static',
format: 'esm',
chunkFileNames: (chunkInfo) => {
return 'chunk-[hash].js';
export default defineConfig([
// Main bundle
{
input: 'src/index.ts',
plugins: [
del({ targets: ['../backend/decky_loader/static/*', '!../backend/decky_loader/static/fallback.js'], force: true }),
commonjs(),
nodeResolve({
browser: true,
}),
externalGlobals({
react: 'SP_REACT',
'react-dom': 'SP_REACTDOM',
// hack to shut up react-markdown
process: '{cwd: () => {}}',
path: '{dirname: () => {}, join: () => {}, basename: () => {}, extname: () => {}}',
url: '{fileURLToPath: (f) => f}',
}),
typescript(),
json(),
replace({
preventAssignment: false,
'process.env.NODE_ENV': JSON.stringify('production'),
}),
image(),
visualizer(),
],
preserveEntrySignatures: false,
treeshake: {
// Assume all external modules have imports with side effects (the default) while allowing decky libraries to treeshake
pureExternalImports: true,
preset: 'smallest'
},
output: {
dir: '../backend/decky_loader/static',
format: 'esm',
chunkFileNames: (chunkInfo) => {
return 'chunk-[hash].js';
},
sourcemap: true,
sourcemapPathTransform: (relativeSourcePath) => relativeSourcePath.replace(/^\.\.\//, `decky://decky/loader/`),
},
onwarn: function (message, handleWarning) {
if (hiddenWarnings.some((warning) => message.code === warning)) return;
handleWarning(message);
},
sourcemap: true,
sourcemapPathTransform: (relativeSourcePath) => relativeSourcePath.replace(/^\.\.\//, `decky://decky/loader/`),
},
onwarn: function (message, handleWarning) {
if (hiddenWarnings.some((warning) => message.code === warning)) return;
handleWarning(message);
},
});
// Fallback
{
input: 'src/fallback.ts',
plugins: [
typescript()
],
output: {
file: '../backend/decky_loader/static/fallback.js',
format: 'esm',
}
}
]);
@@ -94,7 +94,7 @@ const DeckyErrorBoundary: FunctionComponent<DeckyErrorBoundaryProps> = ({ error,
style={{ marginRight: '5px', padding: '5px' }}
onClick={() => {
addLogLine('Restarting Steam...');
SteamClient.User.StartRestart();
SteamClient.User.StartRestart(false);
}}
>
Restart Steam
@@ -121,7 +121,7 @@ const DeckyErrorBoundary: FunctionComponent<DeckyErrorBoundaryProps> = ({ error,
doShutdown();
await sleep(5000);
addLogLine('Restarting Steam...');
SteamClient.User.StartRestart();
SteamClient.User.StartRestart(false);
}}
>
Disable Decky until next boot
@@ -166,7 +166,7 @@ const DeckyErrorBoundary: FunctionComponent<DeckyErrorBoundaryProps> = ({ error,
await sleep(2000);
addLogLine('Restarting Steam...');
await sleep(500);
SteamClient.User.StartRestart();
SteamClient.User.StartRestart(false);
}}
>
Uninstall {errorSource} and restart Decky
+38 -36
View File
@@ -1,37 +1,39 @@
export default function DeckyIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 456" width="512" height="456">
<g>
<path
style={{ fill: 'none' }}
d="M154.33,72.51v49.79c11.78-0.17,23.48,2,34.42,6.39c10.93,4.39,20.89,10.91,29.28,19.18
c8.39,8.27,15.06,18.13,19.61,29c4.55,10.87,6.89,22.54,6.89,34.32c0,11.78-2.34,23.45-6.89,34.32
c-4.55,10.87-11.21,20.73-19.61,29c-8.39,8.27-18.35,14.79-29.28,19.18c-10.94,4.39-22.63,6.56-34.42,6.39v49.77
c36.78,0,72.05-14.61,98.05-40.62c26-26.01,40.61-61.28,40.61-98.05c0-36.78-14.61-72.05-40.61-98.05
C226.38,87.12,191.11,72.51,154.33,72.51z"
/>
import { FC, SVGAttributes } from 'react';
<ellipse
transform="matrix(0.982 -0.1891 0.1891 0.982 -37.1795 32.9988)"
style={{ fill: 'none' }}
cx="154.33"
cy="211.33"
rx="69.33"
ry="69.33"
/>
<path style={{ fill: 'none' }} d="M430,97h-52v187h52c7.18,0,13-5.82,13-13V110C443,102.82,437.18,97,430,97z" />
<path
style={{ fill: 'currentColor' }}
d="M432,27h-54V0H0v361c0,52.47,42.53,95,95,95h188c52.47,0,95-42.53,95-95v-7h54c44.18,0,80-35.82,80-80V107
C512,62.82,476.18,27,432,27z M85,211.33c0-38.29,31.04-69.33,69.33-69.33c38.29,0,69.33,31.04,69.33,69.33
c0,38.29-31.04,69.33-69.33,69.33C116.04,280.67,85,249.62,85,211.33z M252.39,309.23c-26.01,26-61.28,40.62-98.05,40.62v-49.77
c11.78,0.17,23.48-2,34.42-6.39c10.93-4.39,20.89-10.91,29.28-19.18c8.39-8.27,15.06-18.13,19.61-29
c4.55-10.87,6.89-22.53,6.89-34.32c0-11.78-2.34-23.45-6.89-34.32c-4.55-10.87-11.21-20.73-19.61-29
c-8.39-8.27-18.35-14.79-29.28-19.18c-10.94-4.39-22.63-6.56-34.42-6.39V72.51c36.78,0,72.05,14.61,98.05,40.61
c26,26.01,40.61,61.28,40.61,98.05C293,247.96,278.39,283.23,252.39,309.23z M443,271c0,7.18-5.82,13-13,13h-52V97h52
c7.18,0,13,5.82,13,13V271z"
/>
</g>
</svg>
);
}
const DeckyIcon: FC<SVGAttributes<SVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" height="100%" width="100%" viewBox="0 0 512 456" {...props}>
<g>
<path
style={{ fill: 'none' }}
d="M154.33,72.51v49.79c11.78-0.17,23.48,2,34.42,6.39c10.93,4.39,20.89,10.91,29.28,19.18
c8.39,8.27,15.06,18.13,19.61,29c4.55,10.87,6.89,22.54,6.89,34.32c0,11.78-2.34,23.45-6.89,34.32
c-4.55,10.87-11.21,20.73-19.61,29c-8.39,8.27-18.35,14.79-29.28,19.18c-10.94,4.39-22.63,6.56-34.42,6.39v49.77
c36.78,0,72.05-14.61,98.05-40.62c26-26.01,40.61-61.28,40.61-98.05c0-36.78-14.61-72.05-40.61-98.05
C226.38,87.12,191.11,72.51,154.33,72.51z"
/>
<ellipse
transform="matrix(0.982 -0.1891 0.1891 0.982 -37.1795 32.9988)"
style={{ fill: 'none' }}
cx="154.33"
cy="211.33"
rx="69.33"
ry="69.33"
/>
<path style={{ fill: 'none' }} d="M430,97h-52v187h52c7.18,0,13-5.82,13-13V110C443,102.82,437.18,97,430,97z" />
<path
style={{ fill: 'currentColor' }}
d="M432,27h-54V0H0v361c0,52.47,42.53,95,95,95h188c52.47,0,95-42.53,95-95v-7h54c44.18,0,80-35.82,80-80V107
C512,62.82,476.18,27,432,27z M85,211.33c0-38.29,31.04-69.33,69.33-69.33c38.29,0,69.33,31.04,69.33,69.33
c0,38.29-31.04,69.33-69.33,69.33C116.04,280.67,85,249.62,85,211.33z M252.39,309.23c-26.01,26-61.28,40.62-98.05,40.62v-49.77
c11.78,0.17,23.48-2,34.42-6.39c10.93-4.39,20.89-10.91,29.28-19.18c8.39-8.27,15.06-18.13,19.61-29
c4.55-10.87,6.89-22.53,6.89-34.32c0-11.78-2.34-23.45-6.89-34.32c-4.55-10.87-11.21-20.73-19.61-29
c-8.39-8.27-18.35-14.79-29.28-19.18c-10.94-4.39-22.63-6.56-34.42-6.39V72.51c36.78,0,72.05,14.61,98.05,40.61
c26,26.01,40.61,61.28,40.61,98.05C293,247.96,278.39,283.23,252.39,309.23z M443,271c0,7.18-5.82,13-13,13h-52V97h52
c7.18,0,13,5.82,13,13V271z"
/>
</g>
</svg>
);
export default DeckyIcon;
-57
View File
@@ -1,57 +0,0 @@
import type { ToastData } from '@decky/api';
import { joinClassNames } from '@decky/ui';
import { FC, ReactElement, useEffect, useState } from 'react';
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 soundReactElement
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: number | 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;
@@ -1,69 +0,0 @@
import type { ToastData } from '@decky/api';
import { FC, ReactNode, createContext, useContext, useEffect, useState } from 'react';
interface PublicDeckyToasterState {
toasts: Set<ToastData>;
}
export class DeckyToasterState {
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;
children: ReactNode;
}
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>
);
};
+5 -5
View File
@@ -1,4 +1,4 @@
import { DialogButton, Focusable, Router, staticClasses } from '@decky/ui';
import { DialogButton, Focusable, Navigation, staticClasses } from '@decky/ui';
import { CSSProperties, FC } from 'react';
import { useTranslation } from 'react-i18next';
import { BsGearFill } from 'react-icons/bs';
@@ -19,13 +19,13 @@ const TitleView: FC = () => {
const { t } = useTranslation();
const onSettingsClick = () => {
Router.CloseSideMenus();
Router.Navigate('/decky/settings');
Navigation.Navigate('/decky/settings');
Navigation.CloseSideMenus();
};
const onStoreClick = () => {
Router.CloseSideMenus();
Router.Navigate('/decky/store');
Navigation.Navigate('/decky/store');
Navigation.CloseSideMenus();
};
if (activePlugin === null) {
+79 -23
View File
@@ -1,37 +1,38 @@
import type { ToastData } from '@decky/api';
import { findModule, joinClassNames } from '@decky/ui';
import { FunctionComponent } from 'react';
import { Focusable, Navigation, findClassModule, joinClassNames } from '@decky/ui';
import { FC, memo } from 'react';
import Logger from '../logger';
const logger = new Logger('ToastRenderer');
// TODO there are more of these
export enum ToastLocation {
/** Big Picture popup toasts */
GAMEPADUI_POPUP = 1,
/** QAM Notifications tab */
GAMEPADUI_QAM = 3,
}
interface ToastProps {
toast: ToastData;
newIndicator?: boolean;
}
export const toastClasses = findModule((mod) => {
if (typeof mod !== 'object') return false;
interface ToastRendererProps extends ToastProps {
location: ToastLocation;
}
if (mod.ToastPlaceholder) {
return true;
}
const templateClasses = findClassModule((m) => m.ShortTemplate) || {};
return false;
});
// These are memoized as they like to randomly rerender
const templateClasses = findModule((mod) => {
if (typeof mod !== 'object') return false;
if (mod.ShortTemplate) {
return true;
}
return false;
});
const Toast: FunctionComponent<ToastProps> = ({ toast }) => {
const GamepadUIPopupToast: FC<Omit<ToastProps, 'newIndicator'>> = memo(({ toast }) => {
return (
<div
style={{ '--toast-duration': `${toast.duration}ms` } as React.CSSProperties}
onClick={toast.onClick}
className={joinClassNames(templateClasses.ShortTemplate, toast.className || '')}
className={joinClassNames(templateClasses.ShortTemplate, toast.className || '', 'DeckyGamepadUIPopupToast')}
>
{toast.logo && <div className={templateClasses.StandardLogoDimensions}>{toast.logo}</div>}
<div className={joinClassNames(templateClasses.Content, toast.contentClassName || '')}>
@@ -43,6 +44,61 @@ const Toast: FunctionComponent<ToastProps> = ({ toast }) => {
</div>
</div>
);
};
});
export default Toast;
const GamepadUIQAMToast: FC<ToastProps> = memo(({ toast, newIndicator }) => {
// The fields aren't mismatched, the logic for these is just a bit weird.
return (
<Focusable
onActivate={() => {
toast.onClick?.();
Navigation.CloseSideMenus();
}}
className={joinClassNames(
templateClasses.StandardTemplateContainer,
toast.className || '',
'DeckyGamepadUIQAMToast',
)}
>
<div className={templateClasses.StandardTemplate}>
{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>}
{toast.title && <div className={templateClasses.Title}>{toast.title}</div>}
{/* timestamp should always be defined by toaster */}
{/* TODO check how valve does this */}
{toast.timestamp && (
<div className={templateClasses.Timestamp}>
{toast.timestamp.toLocaleTimeString(undefined, { timeStyle: 'short' })}
</div>
)}
</div>
{toast.body && <div className={templateClasses.StandardNotificationDescription}>{toast.body}</div>}
{toast.subtext && <div className={templateClasses.StandardNotificationSubText}>{toast.subtext}</div>}
</div>
{newIndicator && (
<div className={templateClasses.NewIndicator}>
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50" fill="none">
<circle fill="currentColor" cx="25" cy="25" r="25"></circle>
</svg>
</div>
)}
</div>
</Focusable>
);
});
export const ToastRenderer: FC<ToastRendererProps> = memo(({ toast, location, newIndicator }) => {
switch (location) {
default:
logger.warn(`Toast UI not implemented for location ${location}! Falling back to GamepadUIQAMToast.`);
return <GamepadUIQAMToast toast={toast} newIndicator={false} />;
case ToastLocation.GAMEPADUI_POPUP:
return <GamepadUIPopupToast toast={toast} />;
case ToastLocation.GAMEPADUI_QAM:
return <GamepadUIQAMToast toast={toast} newIndicator={newIndicator} />;
}
});
export default ToastRenderer;
@@ -1,10 +1,10 @@
import library from './library';
let patches: Function[] = [];
// import library from './library';
// let patches: Function[] = [];
export function deinitFilepickerPatches() {
patches.forEach((unpatch) => unpatch());
// patches.forEach((unpatch) => unpatch());
}
export async function initFilepickerPatches() {
patches.push(await library());
// patches.push(await library());
}
@@ -10,7 +10,7 @@ import {
} from '@decky/ui';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FaDownload, FaInfo } from 'react-icons/fa';
import { FaDownload, FaFlask, FaInfo } from 'react-icons/fa';
import { setSetting } from '../../../../utils/settings';
import { UpdateBranch } from '../general/BranchSelect';
@@ -91,17 +91,20 @@ export default function TestingVersionList() {
<DialogButton
style={{ height: '40px', minWidth: '60px', marginRight: '10px' }}
onClick={async () => {
DeckyPluginLoader.toaster.toast({
const downloadToast = DeckyPluginLoader.toaster.toast({
title: t('Testing.start_download_toast', { id: version.id }),
body: null,
icon: <FaFlask />,
});
try {
await downloadTestingVersion(version.id, version.head_sha);
downloadToast.dismiss();
} catch (e) {
if (e instanceof Error) {
DeckyPluginLoader.toaster.toast({
title: t('Testing.error'),
body: `${e.name}: ${e.message}`,
icon: <FaFlask />,
});
}
}
+10 -3
View File
@@ -22,9 +22,7 @@ class ErrorBoundaryHook extends Logger {
this.log('Initialized');
window.__ERRORBOUNDARY_HOOK_INSTANCE?.deinit?.();
window.__ERRORBOUNDARY_HOOK_INSTANCE = this;
}
init() {
// valve writes only the sanest of code
const exp = /^\(\)=>\(.\|\|.\(new .\),.\)$/;
const initErrorReportingStore = findModuleExport(
@@ -71,11 +69,16 @@ class ErrorBoundaryHook extends Logger {
});
if (!ErrorBoundary) {
this.error('could not find ValveErrorBoundary');
this.error('@decky/ui could not find ErrorBoundary, skipping patch');
return;
}
this.errorBoundaryPatch = replacePatch(ErrorBoundary.prototype, 'render', function (this: any) {
if (this.state._deckyForceRerender) {
const stateClone = { ...this.state, _deckyForceRerender: null };
this.setState(stateClone);
return null;
}
if (this.state.error) {
const store = Object.getPrototypeOf(this)?.constructor?.sm_ErrorReportingStore || errorReportingStore;
return (
@@ -89,6 +92,10 @@ class ErrorBoundaryHook extends Logger {
}
return callOriginal;
});
// Small hack that gives us a lot more flexibility to force rerenders.
ErrorBoundary.prototype._deckyForceRerender = function (this: any) {
this.setState({ ...this.state, _deckyForceRerender: true });
};
}
public temporarilyDisableReporting() {
+128
View File
@@ -0,0 +1,128 @@
// THIS FILE MUST BE ENTIRELY SELF-CONTAINED! DO NOT USE PACKAGES!
interface Window {
FocusNavController: any;
GamepadNavTree: any;
deckyFallbackLoaded?: boolean;
}
(async () => {
try {
if (window.deckyFallbackLoaded) return;
window.deckyFallbackLoaded = true;
// #region utils
function sleep(ms: number) {
return new Promise((res) => setTimeout(res, ms));
}
// #endregion
// #region DeckyIcon
const fallbackIcon = `
<svg class="fallbackDeckyIcon" xmlns="http://www.w3.org/2000/svg" height="100%" width="100%" viewBox="0 0 512 456">
<g>
<path
style="fill: none;"
d="M154.33,72.51v49.79c11.78-0.17,23.48,2,34.42,6.39c10.93,4.39,20.89,10.91,29.28,19.18
c8.39,8.27,15.06,18.13,19.61,29c4.55,10.87,6.89,22.54,6.89,34.32c0,11.78-2.34,23.45-6.89,34.32
c-4.55,10.87-11.21,20.73-19.61,29c-8.39,8.27-18.35,14.79-29.28,19.18c-10.94,4.39-22.63,6.56-34.42,6.39v49.77
c36.78,0,72.05-14.61,98.05-40.62c26-26.01,40.61-61.28,40.61-98.05c0-36.78-14.61-72.05-40.61-98.05
C226.38,87.12,191.11,72.51,154.33,72.51z"
/>
<ellipse
transform="matrix(0.982 -0.1891 0.1891 0.982 -37.1795 32.9988)"
style="fill: none;"
cx="154.33"
cy="211.33"
rx="69.33"
ry="69.33"
/>
<path style="fill: none;" d="M430,97h-52v187h52c7.18,0,13-5.82,13-13V110C443,102.82,437.18,97,430,97z" />
<path
style="fill: currentColor;"
d="M432,27h-54V0H0v361c0,52.47,42.53,95,95,95h188c52.47,0,95-42.53,95-95v-7h54c44.18,0,80-35.82,80-80V107
C512,62.82,476.18,27,432,27z M85,211.33c0-38.29,31.04-69.33,69.33-69.33c38.29,0,69.33,31.04,69.33,69.33
c0,38.29-31.04,69.33-69.33,69.33C116.04,280.67,85,249.62,85,211.33z M252.39,309.23c-26.01,26-61.28,40.62-98.05,40.62v-49.77
c11.78,0.17,23.48-2,34.42-6.39c10.93-4.39,20.89-10.91,29.28-19.18c8.39-8.27,15.06-18.13,19.61-29
c4.55-10.87,6.89-22.53,6.89-34.32c0-11.78-2.34-23.45-6.89-34.32c-4.55-10.87-11.21-20.73-19.61-29
c-8.39-8.27-18.35-14.79-29.28-19.18c-10.94-4.39-22.63-6.56-34.42-6.39V72.51c36.78,0,72.05,14.61,98.05,40.61
c26,26.01,40.61,61.28,40.61,98.05C293,247.96,278.39,283.23,252.39,309.23z M443,271c0,7.18-5.82,13-13,13h-52V97h52
c7.18,0,13,5.82,13,13V271z"
/>
</g>
</svg>
`;
// #endregion
// #region findSP
// from @decky/ui
function getFocusNavController(): any {
return window.GamepadNavTree?.m_context?.m_controller || window.FocusNavController;
}
function getGamepadNavigationTrees(): any {
const focusNav = getFocusNavController();
const context = focusNav.m_ActiveContext || focusNav.m_LastActiveContext;
return context?.m_rgGamepadNavigationTrees;
}
function findSP(): Window {
// old (SP as host)
if (document.title == 'SP') return window;
// new (SP as popup)
const navTrees = getGamepadNavigationTrees();
return navTrees?.find((x: any) => x.m_ID == 'root_1_').Root.Element.ownerDocument.defaultView;
}
// #endregion
const fallbackCSS = `
.fallbackContainer {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
flex-direction: column;
z-index: 99999999;
pointer-events: none;
position: absolute;
top: 0;
left: 0;
backdrop-filter: blur(8px) brightness(40%);
}
.fallbackDeckyIcon {
width: 96px;
height: 96px;
padding-bottom: 1rem;
}
`;
const fallbackHTML = `
<style>${fallbackCSS}</style>
${fallbackIcon}
<span class="fallbackText">
<b>A crash loop has been detected and Decky has been disabled for this boot.</b>
<br>
<i>Steam will restart in 10 seconds...</i>
</span>
`;
await sleep(4000);
const win = findSP() || window;
const container = Object.assign(document.createElement('div'), {
innerHTML: fallbackHTML,
});
container.classList.add('fallbackContainer');
win.document.body.appendChild(container);
await sleep(10000);
SteamClient.User.StartShutdown(false);
} catch (e) {
console.error('Error showing fallback!', e);
}
})();
+16 -3
View File
@@ -5,13 +5,26 @@ interface Window {
}
(async () => {
// Wait for react to definitely be loaded
while (!window.webpackChunksteamui || window.webpackChunksteamui <= 3) {
// Wait for main webpack chunks to definitely be loaded
console.time('[Decky:Boot] Waiting for main Webpack chunks...');
while (!window.webpackChunksteamui || window.webpackChunksteamui.length < 8) {
await new Promise((r) => setTimeout(r, 10)); // Can't use DFL sleep here.
}
console.timeEnd('[Decky:Boot] Waiting for main Webpack chunks...');
// Wait for the React root to be mounted
console.time('[Decky:Boot] Waiting for React root mount...');
let root;
while (
!(root = document.getElementById('root')) ||
!(root as any)[Object.keys(root).find((k) => k.startsWith('__reactContainer$')) as string]
) {
await new Promise((r) => setTimeout(r, 10)); // Can't use DFL sleep here.
}
console.timeEnd('[Decky:Boot] Waiting for React root mount...');
if (!window.SP_REACT) {
console.debug('[Decky:Boot] Setting up React globals...');
console.debug('[Decky:Boot] Setting up Webpack & React globals...');
// deliberate partial import
const DFLWebpack = await import('@decky/ui/dist/webpack');
window.SP_REACT = DFLWebpack.findModule((m) => m.Component && m.PureComponent && m.useLayoutEffect);
+62 -14
View File
@@ -1,17 +1,19 @@
import { ToastNotification } from '@decky/api';
import {
ModalRoot,
Navigation,
PanelSection,
PanelSectionRow,
QuickAccessTab,
Router,
findSP,
quickAccessMenuClasses,
showModal,
sleep,
} from '@decky/ui';
import { FC, lazy } from 'react';
import { FaExclamationCircle, FaPlug } from 'react-icons/fa';
import { FaDownload, FaExclamationCircle, FaPlug } from 'react-icons/fa';
import DeckyIcon from './components/DeckyIcon';
import { DeckyState, DeckyStateContextProvider, UserInfo, useDeckyState } from './components/DeckyState';
import { File, FileSelectionType } from './components/modals/filepicker';
import { deinitFilepickerPatches, initFilepickerPatches } from './components/modals/filepicker/patches';
@@ -28,7 +30,7 @@ import { HiddenPluginsService } from './hidden-plugins-service';
import Logger from './logger';
import { NotificationService } from './notification-service';
import { InstallType, Plugin, PluginLoadType } from './plugin';
import RouterHook from './router-hook';
import RouterHook, { UIMode } from './router-hook';
import { deinitSteamFixes, initSteamFixes } from './steamfixes';
import { checkForPluginUpdates } from './store';
import TabsHook from './tabs-hook';
@@ -79,11 +81,12 @@ class PluginLoader extends Logger {
// stores a list of plugin names which requested to be reloaded
private pluginReloadQueue: { name: string; version?: string }[] = [];
private loaderUpdateToast?: ToastNotification;
private pluginUpdateToast?: ToastNotification;
constructor() {
super(PluginLoader.name);
this.errorBoundaryHook.init();
DeckyBackend.addEventListener('loader/notify_updates', this.notifyUpdates.bind(this));
DeckyBackend.addEventListener('loader/import_plugin', this.importPlugin.bind(this));
DeckyBackend.addEventListener('loader/unload_plugin', this.unloadPlugin.bind(this));
@@ -169,16 +172,48 @@ class PluginLoader extends Logger {
.then(() => this.log('Initialized'));
}
private checkForSP(): boolean {
try {
return !!findSP();
} catch (e) {
this.warn('Error checking for SP tab', e);
return false;
}
}
private async runCrashChecker() {
const spExists = this.checkForSP();
await sleep(5000);
if (spExists && !this.checkForSP()) {
// SP died after plugin loaded. Give up and let the loader's crash loop detection handle it.
this.error('SP died during startup. Restarting webhelper.');
await this.restartWebhelper();
}
}
private getPluginsFromBackend = DeckyBackend.callable<
[],
{ name: string; version: string; load_type: PluginLoadType }[]
>('loader/get_plugins');
private restartWebhelper = DeckyBackend.callable<[], void>('utilities/restart_webhelper');
private async loadPlugins() {
// wait for SP window to exist before loading plugins
while (!findSP()) {
await sleep(100);
let registration: any;
const uiMode = await new Promise(
(r) =>
(registration = SteamClient.UI.RegisterForUIModeChanged((mode: UIMode) => {
r(mode);
registration.unregister();
})),
);
if (uiMode == UIMode.BigPicture) {
// wait for SP window to exist before loading plugins
while (!findSP()) {
await sleep(100);
}
}
this.runCrashChecker();
const plugins = await this.getPluginsFromBackend();
const pluginLoadPromises = [];
const loadStart = performance.now();
@@ -211,7 +246,9 @@ class PluginLoader extends Logger {
if (versionInfo?.remote && versionInfo?.remote?.tag_name != versionInfo?.current) {
this.deckyState.setHasLoaderUpdate(true);
if (this.notificationService.shouldNotify('deckyUpdates')) {
this.toaster.toast({
this.loaderUpdateToast && this.loaderUpdateToast.dismiss();
await this.routerHook.waitForUnlock();
this.loaderUpdateToast = this.toaster.toast({
title: <TranslationHelper transClass={TranslationClass.PLUGIN_LOADER} transText="decky_title" />,
body: (
<TranslationHelper
@@ -220,7 +257,9 @@ class PluginLoader extends Logger {
i18nArgs={{ tag_name: versionInfo?.remote?.tag_name }}
/>
),
onClick: () => Router.Navigate('/decky/settings'),
logo: <DeckyIcon />,
icon: <FaDownload />,
onClick: () => Navigation.Navigate('/decky/settings'),
});
}
}
@@ -239,7 +278,8 @@ class PluginLoader extends Logger {
public async notifyPluginUpdates() {
const updates = await this.checkPluginUpdates();
if (updates?.size > 0 && this.notificationService.shouldNotify('pluginUpdates')) {
this.toaster.toast({
this.pluginUpdateToast && this.pluginUpdateToast.dismiss();
this.pluginUpdateToast = this.toaster.toast({
title: <TranslationHelper transClass={TranslationClass.PLUGIN_LOADER} transText="decky_title" />,
body: (
<TranslationHelper
@@ -248,7 +288,9 @@ class PluginLoader extends Logger {
i18nArgs={{ count: updates.size }}
/>
),
onClick: () => Router.Navigate('/decky/settings/plugins'),
logo: <DeckyIcon />,
icon: <FaDownload />,
onClick: () => Navigation.Navigate('/decky/settings/plugins'),
});
}
}
@@ -375,6 +417,7 @@ class PluginLoader extends Logger {
version?: string,
loadType: PluginLoadType = PluginLoadType.ESMODULE_V1,
) {
let spExists = this.checkForSP();
try {
switch (loadType) {
case PluginLoadType.ESMODULE_V1:
@@ -422,7 +465,7 @@ class PluginLoader extends Logger {
</PanelSectionRow>
<PanelSectionRow>
<pre style={{ overflowX: 'scroll' }}>
<code>{e instanceof Error ? e.stack : JSON.stringify(e)}</code>
<code>{e instanceof Error ? '' + e.stack : JSON.stringify(e)}</code>
</pre>
</PanelSectionRow>
<PanelSectionRow>
@@ -454,6 +497,12 @@ class PluginLoader extends Logger {
icon: <FaExclamationCircle />,
});
}
if (spExists && !this.checkForSP()) {
// SP died after plugin loaded. Give up and let the loader's crash loop detection handle it.
this.error('SP died after loading plugin. Restarting webhelper.');
await this.restartWebhelper();
}
}
async callServerMethod(methodName: string, args = {}) {
@@ -559,7 +608,6 @@ class PluginLoader extends Logger {
method = request.method;
delete req.method;
}
// this is terrible but a. we're going to redo this entire method anyway and b. it was already terrible
try {
const ret = await DeckyBackend.call<
[method: string, url: string, extra_opts?: any],
+288 -108
View File
@@ -1,5 +1,14 @@
import { ErrorBoundary, Focusable, Patch, afterPatch } from '@decky/ui';
import { FC, ReactElement, ReactNode, cloneElement, createElement, memo } from 'react';
import {
ErrorBoundary,
Patch,
afterPatch,
findInReactTree,
findInTree,
findModuleByExport,
getReactRoot,
sleep,
} from '@decky/ui';
import { FC, ReactElement, ReactNode, cloneElement, createElement } from 'react';
import type { Route } from 'react-router';
import {
@@ -22,16 +31,26 @@ declare global {
}
}
export enum UIMode {
BigPicture = 4,
Desktop = 7,
}
const isPatched = Symbol('is patched');
class RouterHook extends Logger {
private router: any;
private memoizedRouter: any;
private gamepadWrapper: any;
private routerState: DeckyRouterState = new DeckyRouterState();
private globalComponentsState: DeckyGlobalComponentsState = new DeckyGlobalComponentsState();
private wrapperPatch: Patch;
private routerPatch?: Patch;
private renderedComponents: ReactElement[] = [];
private Route: any;
private DeckyGamepadRouterWrapper = this.gamepadRouterWrapper.bind(this);
private DeckyDesktopRouterWrapper = this.desktopRouterWrapper.bind(this);
private DeckyGlobalComponentsWrapper = this.globalComponentsWrapper.bind(this);
private toReplace = new Map<string, ReactNode>();
private desktopRouterPatch?: Patch;
private gamepadRouterPatch?: Patch;
private modeChangeRegistration?: any;
private patchedModes = new Set<number>();
public routes?: any[];
constructor() {
@@ -41,112 +60,272 @@ class RouterHook extends Logger {
window.__ROUTER_HOOK_INSTANCE?.deinit?.();
window.__ROUTER_HOOK_INSTANCE = this;
this.gamepadWrapper = Focusable;
let Route: new () => Route;
// Used to store the new replicated routes we create to allow routes to be unpatched.
const processList = (
routeList: any[],
routes: Map<string, RouterEntry> | null,
routePatches: Map<string, Set<RoutePatch>>,
save: boolean,
) => {
this.debug('Route list: ', routeList);
if (save) this.routes = routeList;
let routerIndex = routeList.length;
if (routes) {
if (!routeList[routerIndex - 1]?.length || routeList[routerIndex - 1]?.length !== routes.size) {
if (routeList[routerIndex - 1]?.length && routeList[routerIndex - 1].length !== routes.size) routerIndex--;
const newRouterArray: (ReactElement | JSX.Element)[] = [];
routes.forEach(({ component, props }, path) => {
newRouterArray.push(
<Route path={path} {...props}>
<ErrorBoundary>{createElement(component)}</ErrorBoundary>
</Route>,
);
});
routeList[routerIndex] = newRouterArray;
}
const reactRouterStackModule = findModuleByExport((e) => e == 'router-backstack', 20);
if (reactRouterStackModule) {
this.Route =
Object.values(reactRouterStackModule).find(
(e) => typeof e == 'function' && /routePath:.\.match\?\.path./.test(e.toString()),
) ||
Object.values(reactRouterStackModule).find(
(e) => typeof e == 'function' && /routePath:null===\(.=.\.match\)/.test(e.toString()),
);
if (!this.Route) {
this.error('Failed to find Route component');
}
routeList.forEach((route: Route, index: number) => {
const replaced = toReplace.get(route?.props?.path as string);
if (replaced) {
routeList[index].props.children = replaced;
toReplace.delete(route?.props?.path as string);
}
if (route?.props?.path && routePatches.has(route.props.path as string)) {
toReplace.set(
route?.props?.path as string,
// @ts-ignore
routeList[index].props.children,
);
routePatches.get(route.props.path as string)?.forEach((patch) => {
const oType = routeList[index].props.children.type;
routeList[index].props.children = patch({
...routeList[index].props,
children: {
...cloneElement(routeList[index].props.children),
type: routeList[index].props.children[isPatched] ? oType : (props) => createElement(oType, props),
},
}).children;
routeList[index].props.children[isPatched] = true;
});
}
} else {
this.error('Failed to find router stack module');
}
this.modeChangeRegistration = SteamClient.UI.RegisterForUIModeChanged((mode: UIMode) => {
this.debug(`UI mode changed to ${mode}`);
if (this.patchedModes.has(mode)) return;
this.patchedModes.add(mode);
this.debug(`Patching router for UI mode ${mode}`);
switch (mode) {
case UIMode.BigPicture:
this.debug('Patching gamepad router');
this.patchGamepadRouter();
break;
// Not fully implemented yet
// case UIMode.Desktop:
// this.debug("Patching desktop router");
// this.patchDesktopRouter();
// break;
default:
this.warn(`Router patch not implemented for UI mode ${mode}`);
break;
}
});
}
private async patchGamepadRouter() {
const root = getReactRoot(document.getElementById('root') as any);
const findRouterNode = () =>
findInReactTree(
root,
(node) =>
typeof node?.pendingProps?.loggedIn == 'undefined' && node?.type?.toString().includes('Settings.Root()'),
);
await this.waitForUnlock();
let routerNode = findRouterNode();
while (!routerNode) {
this.warn('Failed to find Router node, reattempting in 5 seconds.');
await sleep(5000);
await this.waitForUnlock();
routerNode = findRouterNode();
}
if (routerNode) {
// Patch the component globally
this.gamepadRouterPatch = afterPatch(routerNode.elementType, 'type', this.handleGamepadRouterRender.bind(this));
// Swap out the current instance
routerNode.type = routerNode.elementType.type;
if (routerNode?.alternate) {
routerNode.alternate.type = routerNode.type;
}
// Force a full rerender via our custom error boundary
const errorBoundaryNode = findInTree(routerNode, (e) => e?.stateNode?._deckyForceRerender, {
walkable: ['return'],
});
};
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);
errorBoundaryNode?.stateNode?._deckyForceRerender?.();
}
}
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 />);
// Currently unused
// @ts-expect-error 6133
private async patchDesktopRouter() {
const root = getReactRoot(document.getElementById('root') as any);
const findRouterNode = () =>
findInReactTree(root, (node) => node?.elementType?.type?.toString()?.includes('bShowDesktopUIContent:'));
let routerNode = findRouterNode();
while (!routerNode) {
this.warn('Failed to find Router node, reattempting in 5 seconds.');
await sleep(5000);
routerNode = findRouterNode();
}
if (routerNode) {
// this.debug("desktop router node", routerNode);
// Patch the component globally
this.desktopRouterPatch = afterPatch(routerNode.elementType, 'type', this.handleDesktopRouterRender.bind(this));
// Swap out the current instance
routerNode.type = routerNode.elementType.type;
if (routerNode?.alternate) {
routerNode.alternate.type = routerNode.type;
}
return <>{renderedComponents}</>;
};
// Force a full rerender via our custom error boundary
const errorBoundaryNode = findInTree(routerNode, (e) => e?.stateNode?._deckyForceRerender, {
walkable: ['return'],
});
errorBoundaryNode?.stateNode?._deckyForceRerender?.();
// this.debug("desktop router node", routerNode);
// // Patch the component globally
// this.desktopRouterPatch = afterPatch(routerNode.type.prototype, 'render', this.handleDesktopRouterRender.bind(this));
// const stateNodeClone = { render: routerNode.stateNode.render } as any;
// // Patch the current instance. render is readonly so we have to do this.
// Object.assign(stateNodeClone, routerNode.stateNode);
// Object.setPrototypeOf(stateNodeClone, Object.getPrototypeOf(routerNode.stateNode));
// this.desktopRouterFirstInstancePatch = afterPatch(stateNodeClone, 'render', this.handleDesktopRouterRender.bind(this));
// routerNode.stateNode = stateNodeClone;
// // Swap out the current instance
// if (routerNode?.alternate) {
// routerNode.alternate.type = routerNode.type;
// routerNode.alternate.stateNode = routerNode.stateNode;
// }
// routerNode.stateNode.forceUpdate();
// Force a full rerender via our custom error boundary
// const errorBoundaryNode = findInTree(routerNode, e => e?.stateNode?._deckyForceRerender, { walkable: ["return"] });
// errorBoundaryNode?.stateNode?._deckyForceRerender?.();
}
}
this.wrapperPatch = afterPatch(this.gamepadWrapper, 'render', (_: any, ret: any) => {
if (ret?.props?.children?.props?.children?.length == 5 || ret?.props?.children?.props?.children?.length == 4) {
const idx = ret?.props?.children?.props?.children?.length == 4 ? 1 : 2;
const potentialSettingsRootString =
ret.props.children.props.children[idx]?.props?.children?.[0]?.type?.type?.toString() || '';
if (potentialSettingsRootString?.includes('Settings.Root()')) {
if (!this.router) {
this.router = ret.props.children.props.children[idx]?.props?.children?.[0]?.type;
this.routerPatch = afterPatch(this.router, 'type', (_: any, ret: any) => {
if (!Route)
Route = ret.props.children[0].props.children.find((x: any) => x.props.path == '/createaccount').type;
const returnVal = (
<DeckyRouterStateContextProvider deckyRouterState={this.routerState}>
<DeckyWrapper>{ret}</DeckyWrapper>
</DeckyRouterStateContextProvider>
);
return returnVal;
});
this.memoizedRouter = memo(this.router.type);
this.memoizedRouter.isDeckyRouter = true;
}
ret.props.children.props.children.push(
<DeckyGlobalComponentsStateContextProvider deckyGlobalComponentsState={this.globalComponentsState}>
<DeckyGlobalComponentsWrapper />
</DeckyGlobalComponentsStateContextProvider>,
);
ret.props.children.props.children[idx].props.children[0].type = this.memoizedRouter;
}
public async waitForUnlock() {
try {
while (window?.securitystore?.IsLockScreenActive?.()) {
await sleep(500);
}
} catch (e) {
this.warn('Error while checking if unlocked:', e);
}
}
public handleDesktopRouterRender(_: any, ret: any) {
const DeckyDesktopRouterWrapper = this.DeckyDesktopRouterWrapper;
const DeckyGlobalComponentsWrapper = this.DeckyGlobalComponentsWrapper;
this.debug('desktop router render', ret);
if (ret._decky) {
return ret;
}
const returnVal = (
<>
<DeckyRouterStateContextProvider deckyRouterState={this.routerState}>
<DeckyDesktopRouterWrapper>{ret}</DeckyDesktopRouterWrapper>
</DeckyRouterStateContextProvider>
<DeckyGlobalComponentsStateContextProvider deckyGlobalComponentsState={this.globalComponentsState}>
<DeckyGlobalComponentsWrapper />
</DeckyGlobalComponentsStateContextProvider>
</>
);
(returnVal as any)._decky = true;
return returnVal;
}
public handleGamepadRouterRender(_: any, ret: any) {
const DeckyGamepadRouterWrapper = this.DeckyGamepadRouterWrapper;
const DeckyGlobalComponentsWrapper = this.DeckyGlobalComponentsWrapper;
if (ret._decky) {
return ret;
}
const returnVal = (
<>
<DeckyRouterStateContextProvider deckyRouterState={this.routerState}>
<DeckyGamepadRouterWrapper>{ret}</DeckyGamepadRouterWrapper>
</DeckyRouterStateContextProvider>
<DeckyGlobalComponentsStateContextProvider deckyGlobalComponentsState={this.globalComponentsState}>
<DeckyGlobalComponentsWrapper />
</DeckyGlobalComponentsStateContextProvider>
</>
);
(returnVal as any)._decky = true;
return returnVal;
}
private globalComponentsWrapper() {
const { components } = useDeckyGlobalComponentsState();
if (this.renderedComponents.length != components.size) {
this.debug('Rerendering global components');
this.renderedComponents = Array.from(components.values()).map((GComponent) => <GComponent />);
}
return <>{this.renderedComponents}</>;
}
private gamepadRouterWrapper({ children }: { children: ReactElement }) {
// Used to store the new replicated routes we create to allow routes to be unpatched.
const { routes, routePatches } = useDeckyRouterState();
// TODO make more redundant
if (!children?.props?.children?.[0]?.props?.children) {
this.debug('routerWrapper wrong component?', children);
return children;
}
const mainRouteList = children.props.children[0].props.children;
const ingameRouteList = children.props.children[1].props.children; // /appoverlay and /apprunning
this.processList(mainRouteList, routes, routePatches, true);
this.processList(ingameRouteList, null, routePatches, false);
this.debug('Rerendered gamepadui routes list');
return children;
}
private desktopRouterWrapper({ children }: { children: ReactElement }) {
// Used to store the new replicated routes we create to allow routes to be unpatched.
this.debug('desktop router wrapper render', children);
const { routes, routePatches } = useDeckyRouterState();
const routeList = findInReactTree(
children,
(node) => node?.length > 2 && node?.find((elem: any) => elem?.props?.path == '/library/home'),
);
if (!routeList) {
this.debug('routerWrapper wrong component?', children);
return children;
}
const library = children.props.children[1].props.children.props;
if (!Array.isArray(library.children)) {
library.children = [library.children];
}
this.debug('library', library);
this.processList(library.children, routes, routePatches, true);
this.debug('Rerendered desktop routes list');
return children;
}
private processList(
routeList: any[],
routes: Map<string, RouterEntry> | null,
routePatches: Map<string, Set<RoutePatch>>,
save: boolean,
) {
const Route = this.Route;
this.debug('Route list: ', routeList);
if (save) this.routes = routeList;
let routerIndex = routeList.length;
if (routes) {
if (!routeList[routerIndex - 1]?.length || routeList[routerIndex - 1]?.length !== routes.size) {
if (routeList[routerIndex - 1]?.length && routeList[routerIndex - 1].length !== routes.size) routerIndex--;
const newRouterArray: (ReactElement | JSX.Element)[] = [];
routes.forEach(({ component, props }, path) => {
newRouterArray.push(
<Route path={path} {...props}>
<ErrorBoundary>{createElement(component)}</ErrorBoundary>
</Route>,
);
});
routeList[routerIndex] = newRouterArray;
}
}
routeList.forEach((route: Route, index: number) => {
const replaced = this.toReplace.get(route?.props?.path as string);
if (replaced) {
routeList[index].props.children = replaced;
this.toReplace.delete(route?.props?.path as string);
}
if (route?.props?.path && routePatches.has(route.props.path as string)) {
this.toReplace.set(
route?.props?.path as string,
// @ts-ignore
routeList[index].props.children,
);
routePatches.get(route.props.path as string)?.forEach((patch) => {
const oType = routeList[index].props.children.type;
routeList[index].props.children = patch({
...routeList[index].props,
children: {
...cloneElement(routeList[index].props.children),
type: routeList[index].props.children[isPatched] ? oType : (props) => createElement(oType, props),
},
}).children;
routeList[index].props.children[isPatched] = true;
});
}
});
}
@@ -175,8 +354,9 @@ class RouterHook extends Logger {
}
deinit() {
this.wrapperPatch.unpatch();
this.routerPatch?.unpatch();
this.modeChangeRegistration?.unregister();
this.gamepadRouterPatch?.unpatch();
this.desktopRouterPatch?.unpatch();
}
}
+5 -4
View File
@@ -1,5 +1,6 @@
// import reloadFix from './reload';
import restartFix from './restart';
// import restartFix from './restart';
import cefSocketFix from './socket';
let fixes: Function[] = [];
export function deinitSteamFixes() {
@@ -7,6 +8,6 @@ export function deinitSteamFixes() {
}
export async function initSteamFixes() {
// fixes.push(await reloadFix());
fixes.push(await restartFix());
fixes.push(cefSocketFix());
// fixes.push(await restartFix());
}
+16
View File
@@ -0,0 +1,16 @@
import Logger from '../logger';
const logger = new Logger('CEFSocketFix');
const closeCEFSocket = DeckyBackend.callable<[], void>('utilities/close_cef_socket');
export default function cefSocketFix() {
const reg = window.SteamClient?.User?.RegisterForShutdownStart(async () => {
logger.log('Closing CEF socket before shutdown');
await closeCEFSocket();
});
if (reg) logger.debug('CEF shutdown handler ready');
return () => reg?.unregister();
}
+34 -75
View File
@@ -1,5 +1,14 @@
// TabsHook for versions after the Desktop merge
import { ErrorBoundary, Patch, QuickAccessTab, afterPatch, findInReactTree, getReactRoot, sleep } from '@decky/ui';
import {
ErrorBoundary,
Patch,
QuickAccessTab,
afterPatch,
createReactTreePatcher,
findInReactTree,
findModuleByExport,
getReactRoot,
} from '@decky/ui';
import { QuickAccessVisibleStateProvider } from './components/QuickAccessVisibleState';
import Logger from './logger';
@@ -20,7 +29,6 @@ interface Tab {
class TabsHook extends Logger {
// private keys = 7;
tabs: Tab[] = [];
private qAMRoot?: any;
private qamPatch?: Patch;
constructor() {
@@ -32,87 +40,38 @@ class TabsHook extends Logger {
}
init() {
const tree = getReactRoot(document.getElementById('root') as any);
let qAMRoot: any;
const findQAMRoot = (currentNode: any, iters: number): any => {
if (iters >= 80) {
// currently 67
return null;
}
if (
(typeof currentNode?.memoizedProps?.visible == 'boolean' ||
typeof currentNode?.memoizedProps?.active == 'boolean') &&
currentNode?.type?.toString()?.includes('QuickAccessMenuBrowserView')
) {
this.log(`QAM root was found in ${iters} recursion cycles`);
return currentNode;
}
if (currentNode.child) {
let node = findQAMRoot(currentNode.child, iters + 1);
if (node !== null) return node;
}
if (currentNode.sibling) {
let node = findQAMRoot(currentNode.sibling, iters + 1);
if (node !== null) return node;
}
return null;
};
(async () => {
qAMRoot = findQAMRoot(tree, 0);
while (!qAMRoot) {
this.error(
'Failed to find QAM root node, reattempting in 5 seconds. A developer may need to increase the recursion limit.',
);
await sleep(5000);
qAMRoot = findQAMRoot(tree, 0);
}
this.qAMRoot = qAMRoot;
let patchedInnerQAM: any;
this.qamPatch = afterPatch(qAMRoot.return, 'type', (_: any, ret: any) => {
try {
if (!qAMRoot?.child) {
qAMRoot = findQAMRoot(tree, 0);
this.qAMRoot = qAMRoot;
}
if (qAMRoot?.child && !qAMRoot?.child?.type?.decky) {
afterPatch(qAMRoot.child, 'type', (_: any, ret: any) => {
try {
const qamTabsRenderer = findInReactTree(ret, (x) => x?.props?.onFocusNavDeactivated);
if (patchedInnerQAM) {
qamTabsRenderer.type = patchedInnerQAM;
} else {
afterPatch(qamTabsRenderer, 'type', (innerArgs: any, ret: any) => {
const tabs = findInReactTree(ret, (x) => x?.props?.tabs);
this.render(tabs.props.tabs, innerArgs[0].visible);
return ret;
});
patchedInnerQAM = qamTabsRenderer.type;
}
} catch (e) {
this.error('Error patching QAM inner', e);
}
return ret;
});
qAMRoot.child.type.decky = true;
qAMRoot.child.alternate.type = qAMRoot.child.type;
}
} catch (e) {
this.error('Error patching QAM', e);
}
// TODO patch the "embedded" renderer in this module too (seems to be for VR? unsure)
const qamModule = findModuleByExport((e) => e?.type?.toString()?.includes('QuickAccessMenuBrowserView'));
const qamRenderer = Object.values(qamModule).find((e: any) =>
e?.type?.toString()?.includes('QuickAccessMenuBrowserView'),
);
const patchHandler = createReactTreePatcher(
[(tree) => findInReactTree(tree, (node) => node?.props?.onFocusNavDeactivated)],
(args, ret) => {
const tabs = findInReactTree(ret, (x) => x?.props?.tabs);
this.render(tabs.props.tabs, args[0].visible);
return ret;
});
},
'TabsHook',
);
if (qAMRoot.return.alternate) {
qAMRoot.return.alternate.type = qAMRoot.return.type;
this.qamPatch = afterPatch(qamRenderer, 'type', patchHandler);
// Patch already rendered qam
const root = getReactRoot(document.getElementById('root') as any);
const qamNode = root && findInReactTree(root, (n: any) => n.elementType == qamRenderer); // need elementType, because type is actually mobx wrapper
if (qamNode) {
// Only affects this fiber node so we don't need to unpatch here
qamNode.type = qamNode.elementType.type;
if (qamNode?.alternate) {
qamNode.alternate.type = qamNode.type;
}
this.log('Finished initial injection');
})();
}
}
deinit() {
this.qamPatch?.unpatch();
this.qAMRoot.return.alternate.type = this.qAMRoot.return.type;
}
add(tab: Tab) {
+87 -154
View File
@@ -1,19 +1,24 @@
import type { ToastData } from '@decky/api';
import type { ToastData, ToastNotification } from '@decky/api';
import {
Export,
ErrorBoundary,
Patch,
afterPatch,
findClassByName,
findInReactTree,
callOriginal,
findModuleExport,
getReactRoot,
injectFCTrampoline,
replacePatch,
sleep,
} from '@decky/ui';
import { ReactNode } from 'react';
import Toast from './components/Toast';
import Logger from './logger';
// TODO export
enum ToastType {
New,
Update,
Remove,
}
declare global {
interface Window {
__TOASTER_INSTANCE: any;
@@ -23,176 +28,104 @@ declare global {
}
class Toaster extends Logger {
// private routerHook: RouterHook;
// private toasterState: DeckyToasterState = new DeckyToasterState();
private node: any;
private rNode: any;
private audioModule: any;
private finishStartup?: () => void;
private ready: Promise<void> = new Promise((res) => (this.finishStartup = res));
private toasterPatch?: Patch;
private toastPatch?: Patch;
private markReady!: () => void;
private ready = new Promise<void>((r) => (this.markReady = r));
constructor() {
super('Toaster');
// this.routerHook = routerHook;
window.__TOASTER_INSTANCE?.deinit?.();
window.__TOASTER_INSTANCE = this;
this.init();
}
async init() {
// this.routerHook.addGlobalComponent('DeckyToaster', () => (
// <DeckyToasterStateContextProvider deckyToasterState={this.toasterState}>
// <DeckyToaster />
// </DeckyToasterStateContextProvider>
// ));
let instance: any;
const tree = getReactRoot(document.getElementById('root') as any);
const toasterClass1 = findClassByName('GamepadToastPlaceholder');
const toasterClass2 = findClassByName('ToastPlaceholder');
const toasterClass3 = findClassByName('ToastPopup');
const toasterClass4 = findClassByName('GamepadToastPopup');
const findToasterRoot = (currentNode: any, iters: number): any => {
if (iters >= 80) {
// currently 66
return null;
const ValveToastRenderer = findModuleExport((e) => e?.toString()?.includes(`controller:"notification",method:`));
// TODO find a way to undo this if possible?
const patchedRenderer = injectFCTrampoline(ValveToastRenderer);
this.toastPatch = replacePatch(patchedRenderer, 'component', (args: any[]) => {
if (args?.[0]?.group?.decky || args?.[0]?.group?.notifications?.[0]?.decky) {
return args[0].group.notifications.map((notification: any) => (
<ErrorBoundary>
<Toast toast={notification.data} newIndicator={notification.bNewIndicator} location={args?.[0]?.location} />
</ErrorBoundary>
));
}
if (
currentNode?.memoizedProps?.className?.startsWith?.(toasterClass1) ||
currentNode?.memoizedProps?.className?.startsWith?.(toasterClass2) ||
currentNode?.memoizedProps?.className?.startsWith?.(toasterClass3) ||
currentNode?.memoizedProps?.className?.startsWith?.(toasterClass4)
) {
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.warn(
'Failed to find Toaster root node, reattempting in 5 seconds. A developer may need to increase the recursion limit.',
);
await sleep(5000);
instance = findToasterRoot(tree, 0);
}
this.node = instance.return;
this.rNode = findInReactTree(
this.node.return.return,
(node) => node?.stateNode && node.type?.InstallErrorReportingStore,
);
let toast: any;
let renderedToast: ReactNode = null;
let innerPatched: any;
const repatch = () => {
if (this.node && !this.node.type.decky) {
this.toasterPatch = afterPatch(this.node, 'type', (_: any, ret: any) => {
const inner = findInReactTree(ret.props.children, (x) => x?.props?.onDismiss);
if (innerPatched) {
inner.type = innerPatched;
} else {
afterPatch(inner, 'type', (innerArgs: any, ret: any) => {
const currentToast = innerArgs[0]?.notification;
if (currentToast?.decky) {
if (currentToast == toast) {
ret.props.children = renderedToast;
} else {
toast = currentToast;
renderedToast = <Toast toast={toast.data} />;
ret.props.children = renderedToast;
}
} else {
toast = null;
renderedToast = null;
}
return ret;
});
innerPatched = inner.type;
}
return ret;
});
this.node.type.decky = true;
this.node.alternate.type = this.node.type;
}
};
const oRender = Object.getPrototypeOf(this.rNode.stateNode).render;
let int: number | undefined;
this.rNode.stateNode.render = (...args: any[]) => {
const ret = oRender.call(this.rNode.stateNode, ...args);
if (ret && !this?.node?.return?.return) {
int && clearInterval(int);
int = setInterval(() => {
const n = findToasterRoot(tree, 0);
if (n?.return) {
clearInterval(int);
this.node = n.return;
this.rNode = this.node.return;
repatch();
} else {
this.error('Failed to re-grab Toaster node, trying again...');
}
}, 1200);
}
repatch();
return ret;
};
this.rNode.stateNode.shouldComponentUpdate = () => true;
this.rNode.stateNode.forceUpdate();
delete this.rNode.stateNode.shouldComponentUpdate;
this.audioModule = findModuleExport((e: Export) => e.PlayNavSound && e.RegisterCallbackOnPlaySound);
return callOriginal;
});
this.log('Initialized');
this.finishStartup?.();
sleep(4000).then(this.markReady);
}
async toast(toast: ToastData) {
// toast.duration = toast.duration || 5e3;
// this.toasterState.addToast(toast);
await this.ready;
toast(toast: ToastData): ToastNotification {
if (toast.sound === undefined) toast.sound = 6;
if (toast.playSound === undefined) toast.playSound = true;
if (toast.showToast === undefined) toast.showToast = true;
if (toast.timestamp === undefined) toast.timestamp = new Date();
if (toast.showNewIndicator === undefined) toast.showNewIndicator = true;
/* eType 13
13: {
proto: m.mu,
fnTray: null,
showToast: !0,
sound: f.PN.ToastMisc,
eFeature: l.uX
}
*/
let toastData = {
nNotificationID: window.NotificationStore.m_nNextTestNotificationID++,
bNewIndicator: toast.showNewIndicator,
rtCreated: Date.now(),
eType: toast.eType || 11,
eType: toast.eType || 13,
eSource: 1, // Client
nToastDurationMS: toast.duration || (toast.duration = 5e3),
data: toast,
decky: true,
};
// @ts-ignore
toastData.data.appid = () => 0;
if (toast.sound === undefined) toast.sound = 6;
if (toast.playSound === undefined) toast.playSound = true;
if (toast.showToast === undefined) toast.showToast = true;
if (
(window.settingsStore.settings.bDisableAllToasts && !toast.critical) ||
(window.settingsStore.settings.bDisableToastsInGame &&
!toast.critical &&
window.NotificationStore.BIsUserInGame())
)
return;
if (toast.playSound) this.audioModule?.PlayNavSound(toast.sound);
if (toast.showToast) {
window.NotificationStore.m_rgNotificationToasts.push(toastData);
window.NotificationStore.DispatchNextToast();
let group: any;
function fnTray(toast: any, tray: any) {
group = {
eType: toast.eType,
notifications: [toast],
};
tray.unshift(group);
}
const info = {
showToast: toast.showToast,
sound: toast.sound,
eFeature: 0,
toastDurationMS: toastData.nToastDurationMS,
bCritical: toast.critical,
fnTray,
};
const self = this;
let expirationTimeout: number;
const toastResult: ToastNotification = {
data: toast,
dismiss() {
// it checks against the id of notifications[0]
try {
expirationTimeout && clearTimeout(expirationTimeout);
group && window.NotificationStore.RemoveGroupFromTray(group);
} catch (e) {
self.error('Error while dismissing toast:', e);
}
},
};
if (toast.expiration) {
expirationTimeout = setTimeout(() => {
try {
group && window.NotificationStore.RemoveGroupFromTray(group);
} catch (e) {
this.error('Error while dismissing expired toast:', e);
}
}, toast.expiration);
}
this.ready.then(() => window.NotificationStore.ProcessNotification(info, toastData, ToastType.New));
return toastResult;
}
deinit() {
this.toasterPatch?.unpatch();
this.node.alternate.type = this.node.type;
delete this.rNode.stateNode.render;
this.ready = new Promise((res) => (this.finishStartup = res));
// this.routerHook.removeGlobalComponent('DeckyToaster');
this.toastPatch?.unpatch();
}
}