summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAAGaming <aa@mail.catvibers.me>2022-10-24 19:14:56 -0400
committerGitHub <noreply@github.com>2022-10-24 16:14:56 -0700
commit84c3b039c385ad872bb0f22eba7a3d2cd4a5ea10 (patch)
tree20b13066c6256cc6ca1beac085094c7964226a37
parent2e6b3834da357c7e81821ce60bad36f54dd9fa6e (diff)
downloaddecky-loader-84c3b039c385ad872bb0f22eba7a3d2cd4a5ea10.tar.gz
decky-loader-84c3b039c385ad872bb0f22eba7a3d2cd4a5ea10.zip
preview 10/21/2022 fixes (#234)
* initial fixes: everything working except toasts and patch notes * tabshook changes, disable toaster for now * prettier * oops * implement custom toaster because I am tired of Valve's shit also fix QAM not injecting sometimes * remove extra logging * add findSP, fix patch notes, fix vscode screwup * fix patch notes * show error when plugin frontends fail to load * add get_tab_lambda * add css and has_element helpers to Tab * small modals fixup * Don't forceUpdate QuickAccess on stable * add routes prop used to get tabs component * add more dev utils to DFL global
-rw-r--r--backend/browser.py6
-rw-r--r--backend/injector.py142
-rw-r--r--backend/loader.py5
-rw-r--r--backend/main.py54
-rw-r--r--backend/updater.py6
-rw-r--r--backend/utilities.py6
-rw-r--r--frontend/package.json2
-rw-r--r--frontend/pnpm-lock.yaml8
-rw-r--r--frontend/src/components/DeckyGlobalComponentsState.tsx74
-rw-r--r--frontend/src/components/DeckyToaster.tsx54
-rw-r--r--frontend/src/components/DeckyToasterState.tsx69
-rw-r--r--frontend/src/components/Markdown.tsx4
-rw-r--r--frontend/src/components/QuickAccessVisibleState.tsx28
-rw-r--r--frontend/src/components/Toast.tsx27
-rw-r--r--frontend/src/components/settings/pages/general/Updater.tsx9
-rw-r--r--frontend/src/developer.tsx8
-rw-r--r--frontend/src/plugin-loader.tsx23
-rw-r--r--frontend/src/router-hook.tsx89
-rw-r--r--frontend/src/tabs-hook.tsx143
-rw-r--r--frontend/src/toaster.tsx211
-rw-r--r--frontend/src/utils/windows.ts7
21 files changed, 696 insertions, 279 deletions
diff --git a/backend/browser.py b/backend/browser.py
index 3007ae18..69f82bdb 100644
--- a/backend/browser.py
+++ b/backend/browser.py
@@ -16,7 +16,7 @@ from zipfile import ZipFile
# Local modules
from helpers import get_ssl_context, get_user, get_user_group, download_remote_binary_to_path
-from injector import get_tab, inject_to_tab
+from injector import get_gamepadui_tab
logger = getLogger("Browser")
@@ -104,7 +104,7 @@ class PluginBrowser:
async def uninstall_plugin(self, name):
if self.loader.watcher:
self.loader.watcher.disabled = True
- tab = await get_tab("SP")
+ tab = await get_gamepadui_tab()
try:
logger.info("uninstalling " + name)
logger.info(" at dir " + self.find_plugin_folder(name))
@@ -172,7 +172,7 @@ class PluginBrowser:
async def request_plugin_install(self, artifact, name, version, hash):
request_id = str(time())
self.install_requests[request_id] = PluginInstallContext(artifact, name, version, hash)
- tab = await get_tab("SP")
+ tab = await get_gamepadui_tab()
await tab.open_websocket()
await tab.evaluate_js(f"DeckyPluginLoader.addPluginInstallPrompt('{name}', '{version}', '{request_id}', '{hash}')")
diff --git a/backend/injector.py b/backend/injector.py
index de914d17..c0d9cbad 100644
--- a/backend/injector.py
+++ b/backend/injector.py
@@ -3,6 +3,7 @@
from asyncio import sleep
from logging import getLogger
from traceback import format_exc
+from typing import List
from aiohttp import ClientSession
from aiohttp.client_exceptions import ClientConnectorError
@@ -18,6 +19,7 @@ class Tab:
def __init__(self, res) -> None:
self.title = res["title"]
self.id = res["id"]
+ self.url = res["url"]
self.ws_url = res["webSocketDebuggerUrl"]
self.websocket = None
@@ -64,6 +66,26 @@ class Tab:
await self.close_websocket()
return res
+ async def has_global_var(self, var_name, manage_socket=True):
+ res = await self.evaluate_js(f"window['{var_name}'] !== null && window['{var_name}'] !== undefined", False, manage_socket)
+
+ if not "result" in res or not "result" in res["result"] or not "value" in res["result"]["result"]:
+ return False
+
+ return res["result"]["result"]["value"]
+
+ async def close(self, manage_socket=True):
+ if manage_socket:
+ await self.open_websocket()
+
+ res = await self._send_devtools_cmd({
+ "method": "Page.close",
+ }, False)
+
+ if manage_socket:
+ await self.close_websocket()
+ return res
+
async def enable(self):
"""
Enables page domain notifications.
@@ -80,6 +102,18 @@ class Tab:
"method": "Page.disable",
}, False)
+ async def refresh(self):
+ if manage_socket:
+ await self.open_websocket()
+
+ await self._send_devtools_cmd({
+ "method": "Page.reload",
+ }, False)
+
+ if manage_socket:
+ await self.close_websocket()
+
+ return
async def reload_and_evaluate(self, js, manage_socket=True):
"""
Reloads the current tab, with JS to run on load via debugger
@@ -227,6 +261,71 @@ class Tab:
if manage_socket:
await self.close_websocket()
+ async def has_element(self, element_name, manage_socket=True):
+ res = await self.evaluate_js(f"document.getElementById('{element_name}') != null", False, manage_socket)
+
+ if not "result" in res or not "result" in res["result"] or not "value" in res["result"]["result"]:
+ return False
+
+ return res["result"]["result"]["value"]
+
+ async def inject_css(self, style, manage_socket=True):
+ try:
+ css_id = str(uuid.uuid4())
+
+ result = await self.evaluate_js(
+ f"""
+ (function() {{
+ const style = document.createElement('style');
+ style.id = "{css_id}";
+ document.head.append(style);
+ style.textContent = `{style}`;
+ }})()
+ """, False, manage_socket)
+
+ if "exceptionDetails" in result["result"]:
+ return {
+ "success": False,
+ "result": result["result"]
+ }
+
+ return {
+ "success": True,
+ "result": css_id
+ }
+ except Exception as e:
+ return {
+ "success": False,
+ "result": e
+ }
+
+ async def remove_css(self, css_id, manage_socket=True):
+ try:
+ result = await self.evaluate_js(
+ f"""
+ (function() {{
+ let style = document.getElementById("{css_id}");
+
+ if (style.nodeName.toLowerCase() == 'style')
+ style.parentNode.removeChild(style);
+ }})()
+ """, False, manage_socket)
+
+ if "exceptionDetails" in result["result"]:
+ return {
+ "success": False,
+ "result": result
+ }
+
+ return {
+ "success": True
+ }
+ except Exception as e:
+ return {
+ "success": False,
+ "result": e
+ }
+
async def get_steam_resource(self, url):
res = await self.evaluate_js(f'(async function test() {{ return await (await fetch("{url}")).text() }})()', True)
return res["result"]["result"]["value"]
@@ -235,7 +334,7 @@ class Tab:
return self.title
-async def get_tabs():
+async def get_tabs() -> List[Tab]:
async with ClientSession() as web:
res = {}
@@ -257,41 +356,28 @@ async def get_tabs():
raise Exception(f"/json did not return 200. {await res.text()}")
-async def get_tab(tab_name):
+async def get_tab(tab_name) -> Tab:
tabs = await get_tabs()
tab = next((i for i in tabs if i.title == tab_name), None)
if not tab:
raise ValueError(f"Tab {tab_name} not found")
return tab
+async def get_tab_lambda(test) -> Tab:
+ tabs = await get_tabs()
+ tab = next((i for i in tabs if test(i)), None)
+ if not tab:
+ raise ValueError(f"Tab not found by lambda")
+ return tab
+
+async def get_gamepadui_tab() -> Tab:
+ tabs = await get_tabs()
+ tab = next((i for i in tabs if ("https://steamloopback.host/routes/" in i.url and (i.title == "Steam" or i.title == "SP"))), None)
+ if not tab:
+ raise ValueError(f"GamepadUI Tab not found")
+ return tab
async def inject_to_tab(tab_name, js, run_async=False):
tab = await get_tab(tab_name)
return await tab.evaluate_js(js, run_async)
-
-
-async def tab_has_global_var(tab_name, var_name):
- try:
- tab = await get_tab(tab_name)
- except ValueError:
- return False
- res = await tab.evaluate_js(f"window['{var_name}'] !== null && window['{var_name}'] !== undefined", False)
-
- if not "result" in res or not "result" in res["result"] or not "value" in res["result"]["result"]:
- return False
-
- return res["result"]["result"]["value"]
-
-
-async def tab_has_element(tab_name, element_name):
- try:
- tab = await get_tab(tab_name)
- except ValueError:
- return False
- res = await tab.evaluate_js(f"document.getElementById('{element_name}') != null", False)
-
- if not "result" in res or not "result" in res["result"] or not "value" in res["result"]["result"]:
- return False
-
- return res["result"]["result"]["value"]
diff --git a/backend/loader.py b/backend/loader.py
index b4559180..8c48c7ae 100644
--- a/backend/loader.py
+++ b/backend/loader.py
@@ -15,7 +15,7 @@ try:
except UnsupportedLibc:
from watchdog.observers.fsevents import FSEventsObserver as Observer
-from injector import get_tab, inject_to_tab
+from injector import get_tab, get_gamepadui_tab
from plugin import PluginWrapper
@@ -141,7 +141,8 @@ class Loader:
print_exc()
async def dispatch_plugin(self, name, version):
- await inject_to_tab("SP", f"window.importDeckyPlugin('{name}', '{version}')")
+ gpui_tab = await get_gamepadui_tab()
+ await gpui_tab.evaluate_js(f"window.importDeckyPlugin('{name}', '{version}')")
def import_plugins(self):
self.logger.info(f"import plugins from {self.plugin_path}")
diff --git a/backend/main.py b/backend/main.py
index c3f43078..563755ca 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -12,7 +12,7 @@ from traceback import format_exc
import aiohttp_cors
# Partial imports
-from aiohttp import ClientSession
+from aiohttp import ClientSession, client_exceptions
from aiohttp.web import Application, Response, get, run_app, static
from aiohttp_jinja2 import setup as jinja_setup
@@ -22,7 +22,7 @@ from helpers import (REMOTE_DEBUGGER_UNIT, csrf_middleware, get_csrf_token,
get_home_path, get_homebrew_path, get_user,
get_user_group, set_user, set_user_group,
stop_systemd_unit)
-from injector import inject_to_tab, tab_has_global_var
+from injector import get_gamepadui_tab, Tab, get_tabs
from loader import Loader
from settings import SettingsManager
from updater import Updater
@@ -118,17 +118,49 @@ class PluginManager:
# await inject_to_tab("SP", "window.syncDeckyPlugins();")
async def loader_reinjector(self):
- await sleep(2)
- await self.inject_javascript()
while True:
- await sleep(5)
- if not await tab_has_global_var("SP", "deckyHasLoaded"):
- logger.info("Plugin loader isn't present in Steam anymore, reinjecting...")
- await self.inject_javascript()
-
- async def inject_javascript(self, request=None):
+ tab = None
+ while not tab:
+ try:
+ tab = await get_gamepadui_tab()
+ except client_exceptions.ClientConnectorError or client_exceptions.ServerDisconnectedError:
+ logger.debug("Couldn't connect to debugger, waiting 5 seconds.")
+ pass
+ except ValueError:
+ logger.debug("Couldn't find GamepadUI tab, waiting 5 seconds")
+ pass
+ if not tab:
+ await sleep(5)
+ await tab.open_websocket()
+ await tab.enable()
+ await self.inject_javascript(tab, True)
+ async for msg in tab.listen_for_message():
+ logger.debug("Page event: " + str(msg.get("method", None)))
+ if msg.get("method", None) == "Page.domContentEventFired":
+ if not await tab.has_global_var("deckyHasLoaded", False):
+ await self.inject_javascript(tab)
+ if msg.get("method", None) == "Inspector.detached":
+ logger.info("Steam is exiting...")
+ await tab.close_websocket()
+ break
+ # while True:
+ # await sleep(5)
+ # if not await tab.has_global_var("deckyHasLoaded", False):
+ # logger.info("Plugin loader isn't present in Steam anymore, reinjecting...")
+ # await self.inject_javascript(tab)
+
+ async def inject_javascript(self, tab: Tab, first=False, request=None):
+ logger.info("Loading Decky frontend!")
try:
- await inject_to_tab("SP", "try{if (window.deckyHasLoaded) location.reload();window.deckyHasLoaded = true;(async()=>{while(!window.SP_REACT){await new Promise(r => setTimeout(r, 10))};await import('http://localhost:1337/frontend/index.js')})();}catch(e){console.error(e)}", True)
+ if first:
+ if await tab.has_global_var("deckyHasLoaded", False):
+ tabs = await get_tabs()
+ for t in tabs:
+ if t.title != "Steam" and t.title != "SP":
+ logger.debug("Closing tab: " + getattr(t, "title", "Untitled"))
+ await t.close()
+ await sleep(0.5)
+ await tab.evaluate_js("try{if (window.deckyHasLoaded){setTimeout(() => location.reload(), 1000)}window.deckyHasLoaded = true;(async()=>{while(!window.SP_REACT){await new Promise(r => setTimeout(r, 10))};await import('http://localhost:1337/frontend/index.js')})();}catch(e){console.error(e)}", False, False, False)
except:
logger.info("Failed to inject JavaScript into tab\n" + format_exc())
pass
diff --git a/backend/updater.py b/backend/updater.py
index ed1520ab..a209f103 100644
--- a/backend/updater.py
+++ b/backend/updater.py
@@ -9,7 +9,7 @@ from subprocess import call
from aiohttp import ClientSession, web
import helpers
-from injector import get_tab, inject_to_tab
+from injector import get_gamepadui_tab, inject_to_tab
from settings import SettingsManager
logger = getLogger("Updater")
@@ -108,7 +108,7 @@ class Updater:
logger.error("release type: NOT FOUND")
raise ValueError("no valid branch found")
logger.info("Updated remote version information")
- tab = await get_tab("SP")
+ tab = await get_gamepadui_tab()
await tab.evaluate_js(f"window.DeckyPluginLoader.notifyUpdates()", False, True, False)
return await self.get_version()
@@ -125,7 +125,7 @@ class Updater:
version = self.remoteVer["tag_name"]
download_url = self.remoteVer["assets"][0]["browser_download_url"]
- tab = await get_tab("SP")
+ tab = await get_gamepadui_tab()
await tab.open_websocket()
async with ClientSession() as web:
async with web.request("GET", download_url, ssl=helpers.get_ssl_context(), allow_redirects=True) as res:
diff --git a/backend/utilities.py b/backend/utilities.py
index 8d899c0d..8351f3e0 100644
--- a/backend/utilities.py
+++ b/backend/utilities.py
@@ -7,7 +7,7 @@ from asyncio import sleep, start_server, gather, open_connection
from aiohttp import ClientSession, web
from logging import getLogger
-from injector import inject_to_tab, get_tab
+from injector import inject_to_tab, get_gamepadui_tab
import helpers
import subprocess
@@ -248,7 +248,7 @@ class Utilities:
self.start_rdt_proxy(ip, 8097)
script = "if(!window.deckyHasConnectedRDT){window.deckyHasConnectedRDT=true;\n" + await res.text() + "\n}"
self.logger.info("Connected to React DevTools, loading script")
- tab = await get_tab("SP")
+ tab = await get_gamepadui_tab()
# RDT needs to load before React itself to work.
result = await tab.reload_and_evaluate(script)
self.logger.info(result)
@@ -259,7 +259,7 @@ class Utilities:
async def disable_rdt(self):
self.logger.info("Disabling React DevTools")
- tab = await get_tab("SP")
+ tab = await get_gamepadui_tab()
self.rdt_script_id = None
await tab.evaluate_js("location.reload();", False, True, False)
self.logger.info("React DevTools disabled")
diff --git a/frontend/package.json b/frontend/package.json
index 0f28be7a..4c8f4950 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -41,7 +41,7 @@
}
},
"dependencies": {
- "decky-frontend-lib": "^3.6.0",
+ "decky-frontend-lib": "^3.7.0",
"react-file-icon": "^1.2.0",
"react-icons": "^4.4.0",
"react-markdown": "^8.0.3",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 18fc7f36..2bd08f42 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -10,7 +10,7 @@ specifiers:
'@types/react-file-icon': ^1.0.1
'@types/react-router': 5.1.18
'@types/webpack': ^5.28.0
- decky-frontend-lib: ^3.6.0
+ decky-frontend-lib: ^3.7.0
husky: ^8.0.1
import-sort-style-module: ^6.0.0
inquirer: ^8.2.4
@@ -30,7 +30,7 @@ specifiers:
typescript: ^4.7.4
dependencies:
- decky-frontend-lib: 3.6.0
+ decky-frontend-lib: 3.7.0
react-file-icon: 1.2.0_wcqkhtmu7mswc6yz4uyexck3ty
react-icons: 4.4.0_react@16.14.0
react-markdown: 8.0.3_vshvapmxg47tngu7tvrsqpq55u
@@ -944,8 +944,8 @@ packages:
dependencies:
ms: 2.1.2
- /decky-frontend-lib/3.6.0:
- resolution: {integrity: sha512-X3VbTbmW7TnBwPW0ui0xjSVoa2UsuKPwI6nFi7LY2ZzmNytCfszk+ZfJSBm2lD2fqV+btqJzr0qFnWFl+bgjEA==}
+ /decky-frontend-lib/3.7.0:
+ resolution: {integrity: sha512-kq002s74XRrtd0LbN9eVLPtwYQOQhAINKE8X0hsbcz3SVK7wvStVEmepku2m9kONUPN7m+H9eYRqJFGutQybSg==}
dependencies:
minimist: 1.2.7
dev: false
diff --git a/frontend/src/components/DeckyGlobalComponentsState.tsx b/frontend/src/components/DeckyGlobalComponentsState.tsx
new file mode 100644
index 00000000..fe45588b
--- /dev/null
+++ b/frontend/src/components/DeckyGlobalComponentsState.tsx
@@ -0,0 +1,74 @@
+import { FC, createContext, useContext, useEffect, useState } from 'react';
+
+interface PublicDeckyGlobalComponentsState {
+ components: Map<string, FC>;
+}
+
+export class DeckyGlobalComponentsState {
+ // TODO a set would be better
+ private _components = new Map<string, FC>();
+
+ public eventBus = new EventTarget();
+
+ publicState(): PublicDeckyGlobalComponentsState {
+ return { components: this._components };
+ }
+
+ addComponent(path: string, component: FC) {
+ this._components.set(path, component);
+ this.notifyUpdate();
+ }
+
+ removeComponent(path: string) {
+ this._components.delete(path);
+ this.notifyUpdate();
+ }
+
+ private notifyUpdate() {
+ this.eventBus.dispatchEvent(new Event('update'));
+ }
+}
+
+interface DeckyGlobalComponentsContext extends PublicDeckyGlobalComponentsState {
+ addComponent(path: string, component: FC): void;
+ removeComponent(path: string): void;
+}
+
+const DeckyGlobalComponentsContext = createContext<DeckyGlobalComponentsContext>(null as any);
+
+export const useDeckyGlobalComponentsState = () => useContext(DeckyGlobalComponentsContext);
+
+interface Props {
+ deckyGlobalComponentsState: DeckyGlobalComponentsState;
+}
+
+export const DeckyGlobalComponentsStateContextProvider: FC<Props> = ({
+ children,
+ deckyGlobalComponentsState: deckyGlobalComponentsState,
+}) => {
+ const [publicDeckyGlobalComponentsState, setPublicDeckyGlobalComponentsState] =
+ useState<PublicDeckyGlobalComponentsState>({
+ ...deckyGlobalComponentsState.publicState(),
+ });
+
+ useEffect(() => {
+ function onUpdate() {
+ setPublicDeckyGlobalComponentsState({ ...deckyGlobalComponentsState.publicState() });
+ }
+
+ deckyGlobalComponentsState.eventBus.addEventListener('update', onUpdate);
+
+ return () => deckyGlobalComponentsState.eventBus.removeEventListener('update', onUpdate);
+ }, []);
+
+ const addComponent = deckyGlobalComponentsState.addComponent.bind(deckyGlobalComponentsState);
+ const removeComponent = deckyGlobalComponentsState.removeComponent.bind(deckyGlobalComponentsState);
+
+ return (
+ <DeckyGlobalComponentsContext.Provider
+ value={{ ...publicDeckyGlobalComponentsState, addComponent, removeComponent }}
+ >
+ {children}
+ </DeckyGlobalComponentsContext.Provider>
+ );
+};
diff --git a/frontend/src/components/DeckyToaster.tsx b/frontend/src/components/DeckyToaster.tsx
new file mode 100644
index 00000000..eaee75eb
--- /dev/null
+++ b/frontend/src/components/DeckyToaster.tsx
@@ -0,0 +1,54 @@
+import { ToastData, joinClassNames } from 'decky-frontend-lib';
+import { FC, useEffect, useState } from 'react';
+import { ReactElement } from 'react-markdown/lib/react-markdown';
+
+import { useDeckyToasterState } from './DeckyToasterState';
+import Toast, { toastClasses } from './Toast';
+
+interface DeckyToasterProps {}
+
+interface RenderedToast {
+ component: ReactElement;
+ data: ToastData;
+}
+
+const DeckyToaster: FC<DeckyToasterProps> = () => {
+ const { toasts, removeToast } = useDeckyToasterState();
+ const [renderedToast, setRenderedToast] = useState<RenderedToast | null>(null);
+ console.log(toasts);
+ if (toasts.size > 0) {
+ const [activeToast] = toasts;
+ if (!renderedToast || activeToast != renderedToast.data) {
+ // TODO play toast sound
+ console.log('rendering toast', activeToast);
+ setRenderedToast({ component: <Toast key={Math.random()} toast={activeToast} />, data: activeToast });
+ }
+ } else {
+ if (renderedToast) setRenderedToast(null);
+ }
+ useEffect(() => {
+ // not actually node but TS is shit
+ let interval: NodeJS.Timer | null;
+ if (renderedToast) {
+ interval = setTimeout(() => {
+ interval = null;
+ console.log('clear toast', renderedToast.data);
+ removeToast(renderedToast.data);
+ }, (renderedToast.data.duration || 5e3) + 1000);
+ console.log('set int', interval);
+ }
+ return () => {
+ if (interval) {
+ console.log('clearing int', interval);
+ clearTimeout(interval);
+ }
+ };
+ }, [renderedToast]);
+ return (
+ <div className={joinClassNames('deckyToaster', toastClasses.ToastPlaceholder)}>
+ {renderedToast && renderedToast.component}
+ </div>
+ );
+};
+
+export default DeckyToaster;
diff --git a/frontend/src/components/DeckyToasterState.tsx b/frontend/src/components/DeckyToasterState.tsx
new file mode 100644
index 00000000..8732d7f8
--- /dev/null
+++ b/frontend/src/components/DeckyToasterState.tsx
@@ -0,0 +1,69 @@
+import { ToastData } from 'decky-frontend-lib';
+import { FC, createContext, useContext, useEffect, useState } from 'react';
+
+interface PublicDeckyToasterState {
+ toasts: Set<ToastData>;
+}
+
+export class DeckyToasterState {
+ // TODO a set would be better
+ private _toasts: Set<ToastData> = new Set();
+
+ public eventBus = new EventTarget();
+
+ publicState(): PublicDeckyToasterState {
+ return { toasts: this._toasts };
+ }
+
+ addToast(toast: ToastData) {
+ this._toasts.add(toast);
+ this.notifyUpdate();
+ }
+
+ removeToast(toast: ToastData) {
+ this._toasts.delete(toast);
+ this.notifyUpdate();
+ }
+
+ private notifyUpdate() {
+ this.eventBus.dispatchEvent(new Event('update'));
+ }
+}
+
+interface DeckyToasterContext extends PublicDeckyToasterState {
+ addToast(toast: ToastData): void;
+ removeToast(toast: ToastData): void;
+}
+
+const DeckyToasterContext = createContext<DeckyToasterContext>(null as any);
+
+export const useDeckyToasterState = () => useContext(DeckyToasterContext);
+
+interface Props {
+ deckyToasterState: DeckyToasterState;
+}
+
+export const DeckyToasterStateContextProvider: FC<Props> = ({ children, deckyToasterState }) => {
+ const [publicDeckyToasterState, setPublicDeckyToasterState] = useState<PublicDeckyToasterState>({
+ ...deckyToasterState.publicState(),
+ });
+
+ useEffect(() => {
+ function onUpdate() {
+ setPublicDeckyToasterState({ ...deckyToasterState.publicState() });
+ }
+
+ deckyToasterState.eventBus.addEventListener('update', onUpdate);
+
+ return () => deckyToasterState.eventBus.removeEventListener('update', onUpdate);
+ }, []);
+
+ const addToast = deckyToasterState.addToast.bind(deckyToasterState);
+ const removeToast = deckyToasterState.removeToast.bind(deckyToasterState);
+
+ return (
+ <DeckyToasterContext.Provider value={{ ...publicDeckyToasterState, addToast, removeToast }}>
+ {children}
+ </DeckyToasterContext.Provider>
+ );
+};
diff --git a/frontend/src/components/Markdown.tsx b/frontend/src/components/Markdown.tsx
index 278e49cd..045b90a2 100644
--- a/frontend/src/components/Markdown.tsx
+++ b/frontend/src/components/Markdown.tsx
@@ -1,4 +1,4 @@
-import { Focusable } from 'decky-frontend-lib';
+import { Focusable, Router } from 'decky-frontend-lib';
import { FunctionComponent, useRef } from 'react';
import ReactMarkdown, { Options as ReactMarkdownOptions } from 'react-markdown';
import remarkGfm from 'remark-gfm';
@@ -21,8 +21,8 @@ const Markdown: FunctionComponent<MarkdownProps> = (props) => {
<Focusable
onActivate={() => {}}
onOKButton={() => {
- aRef?.current?.click();
props.onDismiss?.();
+ Router.NavigateToExternalWeb(aRef.current!.href);
}}
style={{ display: 'inline' }}
>
diff --git a/frontend/src/components/QuickAccessVisibleState.tsx b/frontend/src/components/QuickAccessVisibleState.tsx
index b5ee3b98..4df7e1a1 100644
--- a/frontend/src/components/QuickAccessVisibleState.tsx
+++ b/frontend/src/components/QuickAccessVisibleState.tsx
@@ -1,13 +1,27 @@
-import { FC, createContext, useContext } from 'react';
+import { FC, createContext, useContext, useEffect, useRef, useState } from 'react';
const QuickAccessVisibleState = createContext<boolean>(true);
export const useQuickAccessVisible = () => useContext(QuickAccessVisibleState);
-interface Props {
- visible: boolean;
-}
-
-export const QuickAccessVisibleStateProvider: FC<Props> = ({ children, visible }) => {
- return <QuickAccessVisibleState.Provider value={visible}>{children}</QuickAccessVisibleState.Provider>;
+export const QuickAccessVisibleStateProvider: FC<{}> = ({ children }) => {
+ const divRef = useRef<HTMLDivElement>(null);
+ const [visible, setVisible] = useState<boolean>(false);
+ useEffect(() => {
+ const doc: Document | void | null = divRef?.current?.ownerDocument;
+ if (!doc) return;
+ setVisible(doc.visibilityState == 'visible');
+ const onChange = (e: Event) => {
+ setVisible(doc.visibilityState == 'visible');
+ };
+ doc.addEventListener('visibilitychange', onChange);
+ return () => {
+ doc.removeEventListener('visibilitychange', onChange);
+ };
+ }, [divRef]);
+ return (
+ <div ref={divRef}>
+ <QuickAccessVisibleState.Provider value={visible}>{children}</QuickAccessVisibleState.Provider>
+ </div>
+ );
};
diff --git a/frontend/src/components/Toast.tsx b/frontend/src/components/Toast.tsx
index 01a436d7..e7a220c2 100644
--- a/frontend/src/components/Toast.tsx
+++ b/frontend/src/components/Toast.tsx
@@ -2,13 +2,10 @@ import { ToastData, findModule, joinClassNames } from 'decky-frontend-lib';
import { FunctionComponent } from 'react';
interface ToastProps {
- toast: {
- data: ToastData;
- nToastDurationMS: number;
- };
+ toast: ToastData;
}
-const toastClasses = findModule((mod) => {
+export const toastClasses = findModule((mod) => {
if (typeof mod !== 'object') return false;
if (mod.ToastPlaceholder) {
@@ -30,21 +27,19 @@ const templateClasses = findModule((mod) => {
const Toast: FunctionComponent<ToastProps> = ({ toast }) => {
return (
- <div
- style={{ '--toast-duration': `${toast.nToastDurationMS}ms` } as React.CSSProperties}
- className={toastClasses.toastEnter}
- >
+ <div className={toastClasses.ToastPopup}>
<div
- onClick={toast.data.onClick}
- className={joinClassNames(templateClasses.ShortTemplate, toast.data.className || '')}
+ style={{ '--toast-duration': `${toast.duration}ms` } as React.CSSProperties}
+ onClick={toast.onClick}
+ className={joinClassNames(templateClasses.ShortTemplate, toast.className || '')}
>
- {toast.data.logo && <div className={templateClasses.StandardLogoDimensions}>{toast.data.logo}</div>}
- <div className={joinClassNames(templateClasses.Content, toast.data.contentClassName || '')}>
+ {toast.logo && <div className={templateClasses.StandardLogoDimensions}>{toast.logo}</div>}
+ <div className={joinClassNames(templateClasses.Content, toast.contentClassName || '')}>
<div className={templateClasses.Header}>
- {toast.data.icon && <div className={templateClasses.Icon}>{toast.data.icon}</div>}
- <div className={templateClasses.Title}>{toast.data.title}</div>
+ {toast.icon && <div className={templateClasses.Icon}>{toast.icon}</div>}
+ <div className={templateClasses.Title}>{toast.title}</div>
</div>
- <div className={templateClasses.Body}>{toast.data.body}</div>
+ <div className={templateClasses.Body}>{toast.body}</div>
</div>
</div>
</div>
diff --git a/frontend/src/components/settings/pages/general/Updater.tsx b/frontend/src/components/settings/pages/general/Updater.tsx
index b4ea8536..f617e0ff 100644
--- a/frontend/src/components/settings/pages/general/Updater.tsx
+++ b/frontend/src/components/settings/pages/general/Updater.tsx
@@ -14,6 +14,7 @@ import { useEffect, useState } from 'react';
import { FaArrowDown } from 'react-icons/fa';
import { VerInfo, callUpdaterMethod, finishUpdate } from '../../../../updater';
+import { findSP } from '../../../../utils/windows';
import { useDeckyState } from '../../../DeckyState';
import InlinePatchNotes from '../../../patchnotes/InlinePatchNotes';
import WithSuspense from '../../../WithSuspense';
@@ -21,6 +22,7 @@ import WithSuspense from '../../../WithSuspense';
const MarkdownRenderer = lazy(() => import('../../../Markdown'));
function PatchNotesModal({ versionInfo, closeModal }: { versionInfo: VerInfo | null; closeModal?: () => {} }) {
+ const SP = findSP();
return (
<Focusable onCancelButton={closeModal}>
<FocusRing>
@@ -50,12 +52,13 @@ function PatchNotesModal({ versionInfo, closeModal }: { versionInfo: VerInfo | n
)}
fnGetId={(id) => id}
nNumItems={versionInfo?.all?.length}
- nHeight={window.innerHeight - 40}
- nItemHeight={window.innerHeight - 40}
+ nHeight={SP.innerHeight - 40}
+ nItemHeight={SP.innerHeight - 40}
nItemMarginX={0}
initialColumn={0}
autoFocus={true}
- fnGetColumnWidth={() => window.innerWidth}
+ fnGetColumnWidth={() => SP.innerWidth}
+ name="Decky Updates"
/>
</FocusRing>
</Focusable>
diff --git a/frontend/src/developer.tsx b/frontend/src/developer.tsx
index b1fe74d6..3b2812fc 100644
--- a/frontend/src/developer.tsx
+++ b/frontend/src/developer.tsx
@@ -1,6 +1,9 @@
import {
ReactRouter,
Router,
+ fakeRenderComponent,
+ findInReactTree,
+ findInTree,
findModule,
findModuleChild,
gamepadDialogClasses,
@@ -71,6 +74,11 @@ export async function startup() {
window.DFL = {
findModuleChild,
findModule,
+ ReactUtils: {
+ fakeRenderComponent,
+ findInReactTree,
+ findInTree,
+ },
Router,
ReactRouter,
classes: {
diff --git a/frontend/src/plugin-loader.tsx b/frontend/src/plugin-loader.tsx
index 400e7484..92c634c9 100644
--- a/frontend/src/plugin-loader.tsx
+++ b/frontend/src/plugin-loader.tsx
@@ -9,7 +9,7 @@ import {
staticClasses,
} from 'decky-frontend-lib';
import { lazy } from 'react';
-import { FaPlug } from 'react-icons/fa';
+import { FaExclamationCircle, FaPlug } from 'react-icons/fa';
import { DeckyState, DeckyStateContextProvider, useDeckyState } from './components/DeckyState';
import LegacyPlugin from './components/LegacyPlugin';
@@ -41,7 +41,7 @@ class PluginLoader extends Logger {
private tabsHook: TabsHook = new TabsHook();
// private windowHook: WindowHook = new WindowHook();
private routerHook: RouterHook = new RouterHook();
- public toaster: Toaster = new Toaster();
+ public toaster: Toaster = new Toaster(this.routerHook);
private deckyState: DeckyState = new DeckyState();
private reloadLock: boolean = false;
@@ -233,13 +233,18 @@ class PluginLoader extends Logger {
},
});
if (res.ok) {
- let plugin_export = await eval(await res.text());
- let plugin = plugin_export(this.createPluginAPI(name));
- this.plugins.push({
- ...plugin,
- name: name,
- version: version,
- });
+ try {
+ let plugin_export = await eval(await res.text());
+ let plugin = plugin_export(this.createPluginAPI(name));
+ this.plugins.push({
+ ...plugin,
+ name: name,
+ version: version,
+ });
+ } catch (e) {
+ this.error('Error loading plugin ' + name, e);
+ this.toaster.toast({ title: 'Error loading ' + name, body: '' + e, icon: <FaExclamationCircle /> });
+ }
} else throw new Error(`${name} frontend_bundle not OK`);
}
diff --git a/frontend/src/router-hook.tsx b/frontend/src/router-hook.tsx
index 8414db2c..bf3ae0cb 100644
--- a/frontend/src/router-hook.tsx
+++ b/frontend/src/router-hook.tsx
@@ -1,8 +1,13 @@
import { Patch, afterPatch, findModuleChild } from 'decky-frontend-lib';
-import { ReactElement, ReactNode, cloneElement, createElement, memo } from 'react';
+import { FC, ReactElement, ReactNode, cloneElement, createElement, memo } from 'react';
import type { Route } from 'react-router';
import {
+ DeckyGlobalComponentsState,
+ DeckyGlobalComponentsStateContextProvider,
+ useDeckyGlobalComponentsState,
+} from './components/DeckyGlobalComponentsState';
+import {
DeckyRouterState,
DeckyRouterStateContextProvider,
RoutePatch,
@@ -22,8 +27,10 @@ class RouterHook extends Logger {
private memoizedRouter: any;
private gamepadWrapper: any;
private routerState: DeckyRouterState = new DeckyRouterState();
+ private globalComponentsState: DeckyGlobalComponentsState = new DeckyGlobalComponentsState();
private wrapperPatch: Patch;
private routerPatch?: Patch;
+ public routes?: any[];
constructor() {
super('RouterHook');
@@ -42,24 +49,28 @@ class RouterHook extends Logger {
let Route: new () => Route;
// Used to store the new replicated routes we create to allow routes to be unpatched.
- let toReplace = new Map<string, ReactNode>();
- const DeckyWrapper = ({ children }: { children: ReactElement }) => {
- const { routes, routePatches } = useDeckyRouterState();
-
- const routeList = children.props.children[0].props.children;
-
+ const processList = (
+ routeList: any[],
+ routes: Map<string, RouterEntry> | null,
+ routePatches: Map<string, Set<RoutePatch>>,
+ save: boolean,
+ ) => {
+ this.debug('Route list: ', routeList);
+ if (save) this.routes = routeList;
let routerIndex = routeList.length;
- if (!routeList[routerIndex - 1]?.length || routeList[routerIndex - 1]?.length !== routes.size) {
- if (routeList[routerIndex - 1]?.length && routeList[routerIndex - 1].length !== routes.size) routerIndex--;
- const newRouterArray: ReactElement[] = [];
- routes.forEach(({ component, props }, path) => {
- newRouterArray.push(
- <Route path={path} {...props}>
- {createElement(component)}
- </Route>,
- );
- });
- routeList[routerIndex] = newRouterArray;
+ if (routes) {
+ if (!routeList[routerIndex - 1]?.length || routeList[routerIndex - 1]?.length !== routes.size) {
+ if (routeList[routerIndex - 1]?.length && routeList[routerIndex - 1].length !== routes.size) routerIndex--;
+ const newRouterArray: ReactElement[] = [];
+ routes.forEach(({ component, props }, path) => {
+ newRouterArray.push(
+ <Route path={path} {...props}>
+ {createElement(component)}
+ </Route>,
+ );
+ });
+ routeList[routerIndex] = newRouterArray;
+ }
}
routeList.forEach((route: Route, index: number) => {
const replaced = toReplace.get(route?.props?.path as string);
@@ -85,19 +96,40 @@ class RouterHook extends Logger {
});
}
});
+ };
+ let toReplace = new Map<string, ReactNode>();
+ const DeckyWrapper = ({ children }: { children: ReactElement }) => {
+ const { routes, routePatches } = useDeckyRouterState();
+ const mainRouteList = children.props.children[0].props.children;
+ const ingameRouteList = children.props.children[1].props.children; // /appoverlay and /apprunning
+ processList(mainRouteList, routes, routePatches, true);
+ processList(ingameRouteList, null, routePatches, false);
+
this.debug('Rerendered routes list');
return children;
};
+ let renderedComponents: ReactElement[] = [];
+
+ const DeckyGlobalComponentsWrapper = () => {
+ const { components } = useDeckyGlobalComponentsState();
+ if (renderedComponents.length != components.size) {
+ this.debug('Rerendering global components');
+ renderedComponents = Array.from(components.values()).map((GComponent) => <GComponent />);
+ }
+ return <>{renderedComponents}</>;
+ };
+
this.wrapperPatch = afterPatch(this.gamepadWrapper, 'render', (_: any, ret: any) => {
- if (ret?.props?.children?.props?.children?.length == 5) {
+ if (ret?.props?.children?.props?.children?.length == 5 || ret?.props?.children?.props?.children?.length == 4) {
+ const idx = ret?.props?.children?.props?.children?.length == 4 ? 1 : 2;
if (
- ret.props.children.props.children[2]?.props?.children?.[0]?.type?.type
+ ret.props.children.props.children[idx]?.props?.children?.[0]?.type?.type
?.toString()
?.includes('GamepadUI.Settings.Root()')
) {
if (!this.router) {
- this.router = ret.props.children.props.children[2]?.props?.children?.[0]?.type;
+ this.router = ret.props.children.props.children[idx]?.props?.children?.[0]?.type;
this.routerPatch = afterPatch(this.router, 'type', (_: any, ret: any) => {
if (!Route)
Route = ret.props.children[0].props.children.find((x: any) => x.props.path == '/createaccount').type;
@@ -111,7 +143,12 @@ class RouterHook extends Logger {
this.memoizedRouter = memo(this.router.type);
this.memoizedRouter.isDeckyRouter = true;
}
- ret.props.children.props.children[2].props.children[0].type = this.memoizedRouter;
+ ret.props.children.props.children.push(
+ <DeckyGlobalComponentsStateContextProvider deckyGlobalComponentsState={this.globalComponentsState}>
+ <DeckyGlobalComponentsWrapper />
+ </DeckyGlobalComponentsStateContextProvider>,
+ );
+ ret.props.children.props.children[idx].props.children[0].type = this.memoizedRouter;
}
}
return ret;
@@ -126,6 +163,14 @@ class RouterHook extends Logger {
return this.routerState.addPatch(path, patch);
}
+ addGlobalComponent(name: string, component: FC) {
+ this.globalComponentsState.addComponent(name, component);
+ }
+
+ removeGlobalComponent(name: string) {
+ this.globalComponentsState.removeComponent(name);
+ }
+
removePatch(path: string, patch: RoutePatch) {
this.routerState.removePatch(path, patch);
}
diff --git a/frontend/src/tabs-hook.tsx b/frontend/src/tabs-hook.tsx
index c5072e27..5929b8a0 100644
--- a/frontend/src/tabs-hook.tsx
+++ b/frontend/src/tabs-hook.tsx
@@ -1,5 +1,4 @@
-import { Patch, QuickAccessTab, afterPatch, sleep } from 'decky-frontend-lib';
-import { memo } from 'react';
+import { QuickAccessTab, quickAccessMenuClasses, sleep } from 'decky-frontend-lib';
import { QuickAccessVisibleStateProvider } from './components/QuickAccessVisibleState';
import Logger from './logger';
@@ -28,15 +27,7 @@ interface Tab {
class TabsHook extends Logger {
// private keys = 7;
tabs: Tab[] = [];
- private quickAccess: any;
- private tabRenderer: any;
- private memoizedQuickAccess: any;
- private cNode: any;
-
- private qAPTree: any;
- private rendererTree: any;
-
- private cNodePatch?: Patch;
+ private oFilter: (...args: any[]) => any;
constructor() {
super('TabsHook');
@@ -46,84 +37,63 @@ class TabsHook extends Logger {
window.__TABS_HOOK_INSTANCE = this;
const self = this;
- const tree = (document.getElementById('root') as any)._reactRootContainer._internalRoot.current;
- let scrollRoot: any;
- async function findScrollRoot(currentNode: any, iters: number): Promise<any> {
- if (iters >= 30) {
- self.error(
- 'Scroll root was not found before hitting the recursion limit, a developer will need to increase the limit.',
- );
- return null;
- }
- currentNode = currentNode?.child;
- if (currentNode?.type?.prototype?.RemoveSmartScrollContainer) {
- self.log(`Scroll root was found in ${iters} recursion cycles`);
- return currentNode;
- }
- if (!currentNode) return null;
- if (currentNode.sibling) {
- let node = await findScrollRoot(currentNode.sibling, iters + 1);
- if (node !== null) return node;
- }
- return await findScrollRoot(currentNode, iters + 1);
- }
- (async () => {
- scrollRoot = await findScrollRoot(tree, 0);
- while (!scrollRoot) {
- this.log('Failed to find scroll root node, reattempting in 5 seconds');
- await sleep(5000);
- scrollRoot = await findScrollRoot(tree, 0);
+ const oFilter = (this.oFilter = Array.prototype.filter);
+ Array.prototype.filter = function patchedFilter(...args: any[]) {
+ if (isTabsArray(this)) {
+ self.render(this);
}
- let newQA: any;
- let newQATabRenderer: any;
- this.cNodePatch = afterPatch(scrollRoot.stateNode, 'render', (_: any, ret: any) => {
- if (!this.quickAccess && ret.props.children.props.children[4]) {
- this.quickAccess = ret?.props?.children?.props?.children[4].type;
- newQA = (...args: any) => {
- const ret = this.quickAccess.type(...args);
- if (ret) {
- if (!newQATabRenderer) {
- this.tabRenderer = ret.props.children[1].children.type;
- newQATabRenderer = (...qamArgs: any[]) => {
- const oFilter = Array.prototype.filter;
- Array.prototype.filter = function (...args: any[]) {
- if (isTabsArray(this)) {
- self.render(this, qamArgs[0].visible);
- }
- // @ts-ignore
- return oFilter.call(this, ...args);
- };
- // TODO remove array hack entirely and use this instead const tabs = ret.props.children.props.children[0].props.children[1].props.children[0].props.children[0].props.tabs
- const ret = this.tabRenderer(...qamArgs);
- Array.prototype.filter = oFilter;
- return ret;
- };
- }
- this.rendererTree = ret.props.children[1].children;
- ret.props.children[1].children.type = newQATabRenderer;
- }
- return ret;
- };
- this.memoizedQuickAccess = memo(newQA);
- this.memoizedQuickAccess.isDeckyQuickAccess = true;
- }
- if (ret.props.children.props.children[4]) {
- this.qAPTree = ret.props.children.props.children[4];
- ret.props.children.props.children[4].type = this.memoizedQuickAccess;
+ // @ts-ignore
+ return oFilter.call(this, ...args);
+ };
+
+ if (document.title != 'SP')
+ try {
+ const tree = (document.getElementById('root') as any)._reactRootContainer._internalRoot.current;
+ let qAMRoot: any;
+ async function findQAMRoot(currentNode: any, iters: number): Promise<any> {
+ if (iters >= 60) {
+ // currently 44
+ return null;
+ }
+ currentNode = currentNode?.child;
+ if (
+ currentNode?.memoizedProps?.className &&
+ currentNode?.memoizedProps?.className.startsWith(quickAccessMenuClasses.ViewPlaceholder)
+ ) {
+ self.log(`QAM root was found in ${iters} recursion cycles`);
+ return currentNode;
+ }
+ if (!currentNode) return null;
+ if (currentNode.sibling) {
+ let node = await findQAMRoot(currentNode.sibling, iters + 1);
+ if (node !== null) return node;
+ }
+ return await findQAMRoot(currentNode, iters + 1);
}
- return ret;
- });
- this.cNode = scrollRoot;
- this.cNode.stateNode.forceUpdate();
- this.log('Finished initial injection');
- })();
+ (async () => {
+ qAMRoot = await 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 = await findQAMRoot(tree, 0);
+ }
+
+ while (!qAMRoot?.stateNode?.forceUpdate) {
+ qAMRoot = qAMRoot.return;
+ }
+ qAMRoot.stateNode.shouldComponentUpdate = () => true;
+ qAMRoot.stateNode.forceUpdate();
+ delete qAMRoot.stateNode.shouldComponentUpdate;
+ })();
+ } catch (e) {
+ this.log('Failed to rerender QAM', e);
+ }
}
deinit() {
- this.cNodePatch?.unpatch();
- if (this.qAPTree) this.qAPTree.type = this.quickAccess;
- if (this.rendererTree) this.rendererTree.type = this.tabRenderer;
- if (this.cNode) this.cNode.stateNode.forceUpdate();
+ Array.prototype.filter = this.oFilter;
}
add(tab: Tab) {
@@ -136,13 +106,14 @@ class TabsHook extends Logger {
this.tabs = this.tabs.filter((tab) => tab.id !== id);
}
- render(existingTabs: any[], visible: boolean) {
+ render(existingTabs: any[]) {
for (const { title, icon, content, id } of this.tabs) {
existingTabs.push({
key: id,
title,
tab: icon,
- panel: <QuickAccessVisibleStateProvider visible={visible}>{content}</QuickAccessVisibleStateProvider>,
+ decky: true,
+ panel: <QuickAccessVisibleStateProvider>{content}</QuickAccessVisibleStateProvider>,
});
}
}
diff --git a/frontend/src/toaster.tsx b/frontend/src/toaster.tsx
index d7c0584f..94b08d70 100644
--- a/frontend/src/toaster.tsx
+++ b/frontend/src/toaster.tsx
@@ -1,8 +1,10 @@
-import { Patch, ToastData, afterPatch, findInReactTree, findModuleChild, sleep } from 'decky-frontend-lib';
-import { ReactNode } from 'react';
+import { Patch, ToastData, sleep } from 'decky-frontend-lib';
+import DeckyToaster from './components/DeckyToaster';
+import { DeckyToasterState, DeckyToasterStateContextProvider } from './components/DeckyToasterState';
import Toast from './components/Toast';
import Logger from './logger';
+import RouterHook from './router-hook';
declare global {
interface Window {
@@ -13,12 +15,15 @@ declare global {
class Toaster extends Logger {
private instanceRetPatch?: Patch;
+ private routerHook: RouterHook;
+ private toasterState: DeckyToasterState = new DeckyToasterState();
private node: any;
private settingsModule: any;
private ready: boolean = false;
- constructor() {
+ constructor(routerHook: RouterHook) {
super('Toaster');
+ this.routerHook = routerHook;
window.__TOASTER_INSTANCE?.deinit?.();
window.__TOASTER_INSTANCE = this;
@@ -26,87 +31,135 @@ class Toaster extends Logger {
}
async init() {
- let instance: any;
-
- while (true) {
- instance = findInReactTree(
- (document.getElementById('root') as any)._reactRootContainer._internalRoot.current,
- (x) => x?.memoizedProps?.className?.startsWith?.('toastmanager_ToastPlaceholder'),
- );
- if (instance) break;
- this.debug('finding instance');
- await sleep(2000);
- }
-
- this.node = instance.return.return;
- let toast: any;
- let renderedToast: ReactNode = null;
- this.node.stateNode.render = (...args: any[]) => {
- const ret = this.node.stateNode.__proto__.render.call(this.node.stateNode, ...args);
- if (ret) {
- this.instanceRetPatch = afterPatch(ret, 'type', (_: any, ret: any) => {
- if (ret?.props?.children[1]?.children?.props) {
- const currentToast = ret.props.children[1].children.props.notification;
- if (currentToast?.decky) {
- if (currentToast == toast) {
- ret.props.children[1].children = renderedToast;
- } else {
- toast = currentToast;
- renderedToast = <Toast toast={toast} />;
- ret.props.children[1].children = renderedToast;
- }
- } else {
- toast = null;
- renderedToast = null;
- }
- }
- return ret;
- });
- this.node.stateNode.shouldComponentUpdate = () => {
- return false;
- };
- delete this.node.stateNode.render;
- }
- return ret;
- };
- this.settingsModule = findModuleChild((m) => {
- if (typeof m !== 'object') return undefined;
- for (let prop in m) {
- if (typeof m[prop]?.settings && m[prop]?.communityPreferences) return m[prop];
- }
- });
- this.log('Initialized');
- this.ready = true;
+ this.routerHook.addGlobalComponent('DeckyToaster', () => (
+ <DeckyToasterStateContextProvider deckyToasterState={this.toasterState}>
+ <DeckyToaster />
+ </DeckyToasterStateContextProvider>
+ ));
+ // let instance: any;
+ // while (true) {
+ // instance = findInReactTree(
+ // (document.getElementById('root') as any)._reactRootContainer._internalRoot.current,
+ // (x) => x?.memoizedProps?.className?.startsWith?.('toastmanager_ToastPlaceholder'),
+ // );
+ // if (instance) break;
+ // this.debug('finding instance');
+ // await sleep(2000);
+ // }
+ // // const windowManager = findModuleChild((m) => {
+ // // if (typeof m !== 'object') return false;
+ // // for (let prop in m) {
+ // // if (m[prop]?.prototype?.GetRenderElement) return m[prop];
+ // // }
+ // // return false;
+ // // });
+ // this.node = instance.return.return;
+ // let toast: any;
+ // let renderedToast: ReactNode = null;
+ // console.log(instance, this.node);
+ // // replacePatch(window.SteamClient.BrowserView, "Destroy", (args: any[]) => {
+ // // console.debug("destroy", args)
+ // // return callOriginal;
+ // // })
+ // // let node = this.node.child.updateQueue.lastEffect;
+ // // while (node.next && !node.deckyPatched) {
+ // // node = node.next;
+ // // if (node.deps[1] == "notificationtoasts") {
+ // // console.log("Deleting destroy");
+ // // node.deckyPatched = true;
+ // // node.create = () => {console.debug("VVVVVVVVVVV")};
+ // // node.destroy = () => {console.debug("AAAAAAAAAAAAAAAAaaaaaaaaaaaaaaa")};
+ // // }
+ // // }
+ // this.node.stateNode.render = (...args: any[]) => {
+ // const ret = this.node.stateNode.__proto__.render.call(this.node.stateNode, ...args);
+ // console.log('toast', ret);
+ // if (ret) {
+ // console.log(ret)
+ // // this.instanceRetPatch = replacePatch(ret, 'type', (innerArgs: any) => {
+ // // console.log("inner toast", innerArgs)
+ // // // @ts-ignore
+ // // const oldEffect = window.SP_REACT.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentDispatcher.current.useEffect;
+ // // // @ts-ignore
+ // // window.SP_REACT.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentDispatcher.current.useEffect = (effect, deps) => {
+ // // console.log(effect, deps)
+ // // if (deps?.[1] == "notificationtoasts") {
+ // // console.log("run")
+ // // effect();
+ // // }
+ // // return oldEffect(effect, deps);
+ // // }
+ // // const ret = this.instanceRetPatch?.original(...args);
+ // // console.log("inner ret", ret)
+ // // // @ts-ignore
+ // // window.SP_REACT.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentDispatcher.current.useEffect = oldEffect;
+ // // return ret
+ // // });
+ // }
+ // // console.log("toast ret", ret)
+ // // if (ret?.props?.children[1]?.children?.props) {
+ // // const currentToast = ret.props.children[1].children.props.notification;
+ // // if (currentToast?.decky) {
+ // // if (currentToast == toast) {
+ // // ret.props.children[1].children = renderedToast;
+ // // } else {
+ // // toast = currentToast;
+ // // renderedToast = <Toast toast={toast} />;
+ // // ret.props.children[1].children = renderedToast;
+ // // }
+ // // } else {
+ // // toast = null;
+ // // renderedToast = null;
+ // // }
+ // // }
+ // // return ret;
+ // // });
+ // // }
+ // return ret;
+ // };
+ // this.settingsModule = findModuleChild((m) => {
+ // if (typeof m !== 'object') return undefined;
+ // for (let prop in m) {
+ // if (typeof m[prop]?.settings && m[prop]?.communityPreferences) return m[prop];
+ // }
+ // });
+ // // const idx = FocusNavController.m_ActiveContext.m_rgGamepadNavigationTrees.findIndex((x: any) => x.m_ID == "ToastContainer");
+ // // if (idx > -1) {
+ // // FocusNavController.m_ActiveContext.m_rgGamepadNavigationTrees.splice(idx, 1)
+ // // }
+ // this.node.stateNode.forceUpdate();
+ // this.node.stateNode.shouldComponentUpdate = () => {
+ // return false;
+ // };
+ // this.log('Initialized');
+ // this.ready = true;
}
- async toast(toast: ToastData) {
- while (!this.ready) {
- await sleep(100);
- }
- const settings = this.settingsModule?.settings;
- let toastData = {
- nNotificationID: window.NotificationStore.m_nNextTestNotificationID++,
- rtCreated: Date.now(),
- eType: 15,
- nToastDurationMS: toast.duration || 5e3,
- data: toast,
- decky: true,
- };
- // @ts-ignore
- toastData.data.appid = () => 0;
- if (
- (settings?.bDisableAllToasts && !toast.critical) ||
- (settings?.bDisableToastsInGame && !toast.critical && window.NotificationStore.BIsUserInGame())
- )
- return;
- window.NotificationStore.m_rgNotificationToasts.push(toastData);
- window.NotificationStore.DispatchNextToast();
+ toast(toast: ToastData) {
+ toast.duration = toast.duration || 5e3;
+ this.toasterState.addToast(toast);
+ // const settings = this.settingsModule?.settings;
+ // let toastData = {
+ // nNotificationID: window.NotificationStore.m_nNextTestNotificationID++,
+ // rtCreated: Date.now(),
+ // eType: 15,
+ // nToastDurationMS: toast.duration || 5e3,
+ // data: toast,
+ // decky: true,
+ // };
+ // // @ts-ignore
+ // toastData.data.appid = () => 0;
+ // if (
+ // (settings?.bDisableAllToasts && !toast.critical) ||
+ // (settings?.bDisableToastsInGame && !toast.critical && window.NotificationStore.BIsUserInGame())
+ // )
+ // return;
+ // window.NotificationStore.m_rgNotificationToasts.push(toastData);
+ // window.NotificationStore.DispatchNextToast();
}
deinit() {
- this.instanceRetPatch?.unpatch();
- this.node && delete this.node.stateNode.shouldComponentUpdate;
- this.node && this.node.stateNode.forceUpdate();
+ this.routerHook.removeGlobalComponent('DeckyToaster');
}
}
diff --git a/frontend/src/utils/windows.ts b/frontend/src/utils/windows.ts
new file mode 100644
index 00000000..2b5181d8
--- /dev/null
+++ b/frontend/src/utils/windows.ts
@@ -0,0 +1,7 @@
+export function findSP(): Window {
+ // old (SP as host)
+ if (document.title == 'SP') return window;
+ // new (SP as popup)
+ return FocusNavController.m_ActiveContext.m_rgGamepadNavigationTrees.find((x: any) => x.m_ID == 'root_1_').Root
+ .Element.ownerDocument.defaultView;
+}