diff options
| author | AAGaming <aa@mail.catvibers.me> | 2022-10-24 19:14:56 -0400 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2022-10-24 16:14:56 -0700 |
| commit | 84c3b039c385ad872bb0f22eba7a3d2cd4a5ea10 (patch) | |
| tree | 20b13066c6256cc6ca1beac085094c7964226a37 | |
| parent | 2e6b3834da357c7e81821ce60bad36f54dd9fa6e (diff) | |
| download | decky-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.py | 6 | ||||
| -rw-r--r-- | backend/injector.py | 142 | ||||
| -rw-r--r-- | backend/loader.py | 5 | ||||
| -rw-r--r-- | backend/main.py | 54 | ||||
| -rw-r--r-- | backend/updater.py | 6 | ||||
| -rw-r--r-- | backend/utilities.py | 6 | ||||
| -rw-r--r-- | frontend/package.json | 2 | ||||
| -rw-r--r-- | frontend/pnpm-lock.yaml | 8 | ||||
| -rw-r--r-- | frontend/src/components/DeckyGlobalComponentsState.tsx | 74 | ||||
| -rw-r--r-- | frontend/src/components/DeckyToaster.tsx | 54 | ||||
| -rw-r--r-- | frontend/src/components/DeckyToasterState.tsx | 69 | ||||
| -rw-r--r-- | frontend/src/components/Markdown.tsx | 4 | ||||
| -rw-r--r-- | frontend/src/components/QuickAccessVisibleState.tsx | 28 | ||||
| -rw-r--r-- | frontend/src/components/Toast.tsx | 27 | ||||
| -rw-r--r-- | frontend/src/components/settings/pages/general/Updater.tsx | 9 | ||||
| -rw-r--r-- | frontend/src/developer.tsx | 8 | ||||
| -rw-r--r-- | frontend/src/plugin-loader.tsx | 23 | ||||
| -rw-r--r-- | frontend/src/router-hook.tsx | 89 | ||||
| -rw-r--r-- | frontend/src/tabs-hook.tsx | 143 | ||||
| -rw-r--r-- | frontend/src/toaster.tsx | 211 | ||||
| -rw-r--r-- | frontend/src/utils/windows.ts | 7 |
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; +} |
