mirror of
https://github.com/SteamDeckHomebrew/decky-loader.git
synced 2026-07-01 07:29:21 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1265672067 | |||
| b8acc849bc | |||
| 65b6883dcc | |||
| 166c7ea8a7 | |||
| ddc807340c | |||
| 7fc51c8b7d | |||
| 131f0961ff | |||
| 75aa1e4851 |
Vendored
+1
-1
@@ -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": []
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -1,5 +1,6 @@
|
||||
[Unit]
|
||||
Description=SteamDeck Plugin Loader
|
||||
After=network.target
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
|
||||
Vendored
+1
@@ -1,5 +1,6 @@
|
||||
[Unit]
|
||||
Description=SteamDeck Plugin Loader
|
||||
After=network.target
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+10
-10
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 />,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user