diff options
| -rw-r--r-- | backend/injector.py | 196 | ||||
| -rw-r--r-- | backend/utilities.py | 73 | ||||
| -rw-r--r-- | frontend/src/components/Toast.tsx | 2 | ||||
| -rw-r--r-- | frontend/src/components/settings/index.tsx | 50 | ||||
| -rw-r--r-- | frontend/src/components/settings/pages/developer/index.tsx | 84 | ||||
| -rw-r--r-- | frontend/src/components/settings/pages/general/index.tsx | 24 | ||||
| -rw-r--r-- | frontend/src/developer.tsx | 88 | ||||
| -rw-r--r-- | frontend/src/index.tsx | 28 | ||||
| -rw-r--r-- | frontend/src/plugin-loader.tsx | 9 | ||||
| -rw-r--r-- | frontend/src/toaster.tsx | 5 | ||||
| -rw-r--r-- | frontend/src/utils/hooks/useSetting.ts | 24 | ||||
| -rw-r--r-- | frontend/src/utils/settings.ts | 24 |
12 files changed, 527 insertions, 80 deletions
diff --git a/backend/injector.py b/backend/injector.py index 82436adf..de914d17 100644 --- a/backend/injector.py +++ b/backend/injector.py @@ -1,7 +1,7 @@ -#Injector code from https://github.com/SteamDeckHomebrew/steamdeck-ui-inject. More info on how it works there. +# Injector code from https://github.com/SteamDeckHomebrew/steamdeck-ui-inject. More info on how it works there. from asyncio import sleep -from logging import debug, getLogger +from logging import getLogger from traceback import format_exc from aiohttp import ClientSession @@ -11,7 +11,10 @@ BASE_ADDRESS = "http://localhost:8080" logger = getLogger("Injector") + class Tab: + cmd_id = 0 + def __init__(self, res) -> None: self.title = res["title"] self.id = res["id"] @@ -24,14 +27,24 @@ class Tab: self.client = ClientSession() self.websocket = await self.client.ws_connect(self.ws_url) + async def close_websocket(self): + await self.client.close() + async def listen_for_message(self): async for message in self.websocket: - yield message + data = message.json() + yield data async def _send_devtools_cmd(self, dc, receive=True): if self.websocket: + self.cmd_id += 1 + dc["id"] = self.cmd_id await self.websocket.send_json(dc) - return (await self.websocket.receive_json()) if receive else None + if receive: + async for msg in self.listen_for_message(): + if "id" in msg and msg["id"] == dc["id"]: + return msg + return None raise RuntimeError("Websocket not opened") async def evaluate_js(self, js, run_async=False, manage_socket=True, get_result=True): @@ -39,7 +52,6 @@ class Tab: await self.open_websocket() res = await self._send_devtools_cmd({ - "id": 1, "method": "Runtime.evaluate", "params": { "expression": js, @@ -49,9 +61,172 @@ class Tab: }, get_result) if manage_socket: - await self.client.close() + await self.close_websocket() return res + async def enable(self): + """ + Enables page domain notifications. + """ + await self._send_devtools_cmd({ + "method": "Page.enable", + }, False) + + async def disable(self): + """ + Disables page domain notifications. + """ + await self._send_devtools_cmd({ + "method": "Page.disable", + }, False) + + async def reload_and_evaluate(self, js, manage_socket=True): + """ + Reloads the current tab, with JS to run on load via debugger + """ + if manage_socket: + await self.open_websocket() + + await self._send_devtools_cmd({ + "method": "Debugger.enable" + }, True) + + await self._send_devtools_cmd({ + "method": "Runtime.evaluate", + "params": { + "expression": "location.reload();", + "userGesture": True, + "awaitPromise": False + } + }, False) + + breakpoint_res = await self._send_devtools_cmd({ + "method": "Debugger.setInstrumentationBreakpoint", + "params": { + "instrumentation": "beforeScriptExecution" + } + }, True) + + logger.info(breakpoint_res) + + # Page finishes loading when breakpoint hits + + for x in range(20): + # this works around 1/5 of the time, so just send it 8 times. + # the js accounts for being injected multiple times allowing only one instance to run at a time anyway + await self._send_devtools_cmd({ + "method": "Runtime.evaluate", + "params": { + "expression": js, + "userGesture": True, + "awaitPromise": False + } + }, False) + + await self._send_devtools_cmd({ + "method": "Debugger.removeBreakpoint", + "params": { + "breakpointId": breakpoint_res["result"]["breakpointId"] + } + }, False) + + for x in range(4): + await self._send_devtools_cmd({ + "method": "Debugger.resume" + }, False) + + await self._send_devtools_cmd({ + "method": "Debugger.disable" + }, True) + + if manage_socket: + await self.close_websocket() + return + + async def add_script_to_evaluate_on_new_document(self, js, add_dom_wrapper=True, manage_socket=True, get_result=True): + """ + How the underlying call functions is not particularly clear from the devtools docs, so stealing puppeteer's description: + + Adds a function which would be invoked in one of the following scenarios: + * whenever the page is navigated + * whenever the child frame is attached or navigated. In this case, the + function is invoked in the context of the newly attached frame. + + The function is invoked after the document was created but before any of + its scripts were run. This is useful to amend the JavaScript environment, + e.g. to seed `Math.random`. + + Parameters + ---------- + js : str + The script to evaluate on new document + add_dom_wrapper : bool + True to wrap the script in a wait for the 'DOMContentLoaded' event. + DOM will usually not exist when this execution happens, + so it is necessary to delay til DOM is loaded if you are modifying it + manage_socket : bool + True to have this function handle opening/closing the websocket for this tab + get_result : bool + True to wait for the result of this call + + Returns + ------- + int or None + The identifier of the script added, used to remove it later. + (see remove_script_to_evaluate_on_new_document below) + None is returned if `get_result` is False + """ + + wrappedjs = """ + function scriptFunc() { + {js} + } + if (document.readyState === 'loading') { + addEventListener('DOMContentLoaded', () => { + scriptFunc(); + }); + } else { + scriptFunc(); + } + """.format(js=js) if add_dom_wrapper else js + + if manage_socket: + await self.open_websocket() + + res = await self._send_devtools_cmd({ + "method": "Page.addScriptToEvaluateOnNewDocument", + "params": { + "source": wrappedjs + } + }, get_result) + + if manage_socket: + await self.close_websocket() + return res + + async def remove_script_to_evaluate_on_new_document(self, script_id, manage_socket=True): + """ + Removes a script from a page that was added with `add_script_to_evaluate_on_new_document` + + Parameters + ---------- + script_id : int + The identifier of the script to remove (returned from `add_script_to_evaluate_on_new_document`) + """ + + if manage_socket: + await self.open_websocket() + + res = await self._send_devtools_cmd({ + "method": "Page.removeScriptToEvaluateOnNewDocument", + "params": { + "identifier": script_id + } + }, False) + + if manage_socket: + await self.close_websocket() + 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"] @@ -59,6 +234,7 @@ class Tab: def __repr__(self): return self.title + async def get_tabs(): async with ClientSession() as web: res = {} @@ -80,6 +256,7 @@ async def get_tabs(): else: raise Exception(f"/json did not return 200. {await res.text()}") + async def get_tab(tab_name): tabs = await get_tabs() tab = next((i for i in tabs if i.title == tab_name), None) @@ -87,11 +264,13 @@ async def get_tab(tab_name): raise ValueError(f"Tab {tab_name} 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) @@ -104,14 +283,15 @@ async def tab_has_global_var(tab_name, var_name): 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"]
\ No newline at end of file + return res["result"]["result"]["value"] diff --git a/backend/utilities.py b/backend/utilities.py index 853f60d2..8d899c0d 100644 --- a/backend/utilities.py +++ b/backend/utilities.py @@ -1,10 +1,13 @@ import uuid import os from json.decoder import JSONDecodeError +from traceback import format_exc +from asyncio import sleep, start_server, gather, open_connection from aiohttp import ClientSession, web -from injector import inject_to_tab +from logging import getLogger +from injector import inject_to_tab, get_tab import helpers import subprocess @@ -26,9 +29,17 @@ class Utilities: "disallow_remote_debugging": self.disallow_remote_debugging, "set_setting": self.set_setting, "get_setting": self.get_setting, - "filepicker_ls": self.filepicker_ls + "filepicker_ls": self.filepicker_ls, + "disable_rdt": self.disable_rdt, + "enable_rdt": self.enable_rdt } + self.logger = getLogger("Utilities") + + self.rdt_proxy_server = None + self.rdt_script_id = None + self.rdt_proxy_task = None + if context: context.web_app.add_routes([ web.post("/methods/{method_name}", self._handle_server_method_call) @@ -194,3 +205,61 @@ class Utilities: "realpath": os.path.realpath(path), "files": files } + + # Based on https://stackoverflow.com/a/46422554/13174603 + def start_rdt_proxy(self, ip, port): + async def pipe(reader, writer): + try: + while not reader.at_eof(): + writer.write(await reader.read(2048)) + finally: + writer.close() + async def handle_client(local_reader, local_writer): + try: + remote_reader, remote_writer = await open_connection( + ip, port) + pipe1 = pipe(local_reader, remote_writer) + pipe2 = pipe(remote_reader, local_writer) + await gather(pipe1, pipe2) + finally: + local_writer.close() + + self.rdt_proxy_server = start_server(handle_client, "127.0.0.1", port) + self.rdt_proxy_task = self.context.loop.create_task(self.rdt_proxy_server) + + def stop_rdt_proxy(self): + if self.rdt_proxy_server: + self.rdt_proxy_server.close() + self.rdt_proxy_task.cancel() + + async def enable_rdt(self): + # TODO un-hardcode port + try: + self.stop_rdt_proxy() + ip = self.context.settings.getSetting("developer.rdt.ip", None) + + if ip != None: + self.logger.info("Connecting to React DevTools at " + ip) + async with ClientSession() as web: + async with web.request("GET", "http://" + ip + ":8097", ssl=helpers.get_ssl_context()) as res: + if res.status != 200: + self.logger.error("Failed to connect to React DevTools at " + ip) + return False + self.start_rdt_proxy(ip, 8097) + script = "if(!window.deckyHasConnectedRDT){window.deckyHasConnectedRDT=true;\n" + await res.text() + "\n}" + self.logger.info("Connected to React DevTools, loading script") + tab = await get_tab("SP") + # RDT needs to load before React itself to work. + result = await tab.reload_and_evaluate(script) + self.logger.info(result) + + except Exception: + self.logger.error("Failed to connect to React DevTools") + self.logger.error(format_exc()) + + async def disable_rdt(self): + self.logger.info("Disabling React DevTools") + tab = await get_tab("SP") + self.rdt_script_id = None + await tab.evaluate_js("location.reload();", False, True, False) + self.logger.info("React DevTools disabled") diff --git a/frontend/src/components/Toast.tsx b/frontend/src/components/Toast.tsx index 559c37c6..01a436d7 100644 --- a/frontend/src/components/Toast.tsx +++ b/frontend/src/components/Toast.tsx @@ -32,7 +32,7 @@ const Toast: FunctionComponent<ToastProps> = ({ toast }) => { return ( <div style={{ '--toast-duration': `${toast.nToastDurationMS}ms` } as React.CSSProperties} - className={joinClassNames(toastClasses.ToastPopup, toastClasses.toastEnter)} + className={toastClasses.toastEnter} > <div onClick={toast.data.onClick} diff --git a/frontend/src/components/settings/index.tsx b/frontend/src/components/settings/index.tsx index eb3a8bbd..01f7d407 100644 --- a/frontend/src/components/settings/index.tsx +++ b/frontend/src/components/settings/index.tsx @@ -1,25 +1,39 @@ import { SidebarNavigation } from 'decky-frontend-lib'; +import { lazy } from 'react'; +import { useSetting } from '../../utils/hooks/useSetting'; +import WithSuspense from '../WithSuspense'; import GeneralSettings from './pages/general'; import PluginList from './pages/plugin_list'; +const DeveloperSettings = lazy(() => import('./pages/developer')); + export default function SettingsPage() { - return ( - <SidebarNavigation - title="Decky Settings" - showTitle - pages={[ - { - title: 'General', - content: <GeneralSettings />, - route: '/decky/settings/general', - }, - { - title: 'Plugins', - content: <PluginList />, - route: '/decky/settings/plugins', - }, - ]} - /> - ); + const [isDeveloper, setIsDeveloper] = useSetting<boolean>('developer.enabled', false); + + const pages = [ + { + title: 'General', + content: <GeneralSettings isDeveloper={isDeveloper} setIsDeveloper={setIsDeveloper} />, + route: '/decky/settings/general', + }, + { + title: 'Plugins', + content: <PluginList />, + route: '/decky/settings/plugins', + }, + ]; + + if (isDeveloper) + pages.push({ + title: 'Developer', + content: ( + <WithSuspense> + <DeveloperSettings /> + </WithSuspense> + ), + route: '/decky/settings/developer', + }); + + return <SidebarNavigation title="Decky Settings" showTitle pages={pages} />; } diff --git a/frontend/src/components/settings/pages/developer/index.tsx b/frontend/src/components/settings/pages/developer/index.tsx new file mode 100644 index 00000000..447c9606 --- /dev/null +++ b/frontend/src/components/settings/pages/developer/index.tsx @@ -0,0 +1,84 @@ +import { Field, Focusable, TextField, Toggle } from 'decky-frontend-lib'; +import { useRef } from 'react'; +import { FaReact, FaSteamSymbol } from 'react-icons/fa'; + +import { setShouldConnectToReactDevTools, setShowValveInternal } from '../../../../developer'; +import { useSetting } from '../../../../utils/hooks/useSetting'; + +export default function DeveloperSettings() { + const [enableValveInternal, setEnableValveInternal] = useSetting<boolean>('developer.valve_internal', false); + const [reactDevtoolsEnabled, setReactDevtoolsEnabled] = useSetting<boolean>('developer.rdt.enabled', false); + const [reactDevtoolsIP, setReactDevtoolsIP] = useSetting<string>('developer.rdt.ip', ''); + const textRef = useRef<HTMLDivElement>(null); + + return ( + <> + <Field + label="Enable Valve Internal" + description={ + <span style={{ whiteSpace: 'pre-line' }}> + Enables the Valve internal developer menu.{' '} + <span style={{ color: 'red' }}>Do not touch anything in this menu unless you know what it does.</span> + </span> + } + icon={<FaSteamSymbol style={{ display: 'block' }} />} + > + <Toggle + value={enableValveInternal} + onChange={(toggleValue) => { + setEnableValveInternal(toggleValue); + setShowValveInternal(toggleValue); + }} + /> + </Field>{' '} + <Focusable + onTouchEnd={ + reactDevtoolsIP == '' + ? () => { + (textRef.current?.childNodes[0] as HTMLInputElement)?.focus(); + } + : undefined + } + onClick={ + reactDevtoolsIP == '' + ? () => { + (textRef.current?.childNodes[0] as HTMLInputElement)?.focus(); + } + : undefined + } + onOKButton={ + reactDevtoolsIP == '' + ? () => { + (textRef.current?.childNodes[0] as HTMLInputElement)?.focus(); + } + : undefined + } + > + <Field + label="Enable React DevTools" + description={ + <> + <span style={{ whiteSpace: 'pre-line' }}> + Enables connection to a computer running React DevTools. Changing this setting will reload Steam. Set + the IP address before enabling. + </span> + <div ref={textRef}> + <TextField label={'IP'} value={reactDevtoolsIP} onChange={(e) => setReactDevtoolsIP(e?.target.value)} /> + </div> + </> + } + icon={<FaReact style={{ display: 'block' }} />} + > + <Toggle + value={reactDevtoolsEnabled} + disabled={reactDevtoolsIP == ''} + onChange={(toggleValue) => { + setReactDevtoolsEnabled(toggleValue); + setShouldConnectToReactDevTools(toggleValue); + }} + /> + </Field> + </Focusable> + </> + ); +} diff --git a/frontend/src/components/settings/pages/general/index.tsx b/frontend/src/components/settings/pages/general/index.tsx index a37d8dab..768cfafb 100644 --- a/frontend/src/components/settings/pages/general/index.tsx +++ b/frontend/src/components/settings/pages/general/index.tsx @@ -1,13 +1,19 @@ -import { DialogButton, Field, TextField } from 'decky-frontend-lib'; +import { DialogButton, Field, TextField, Toggle } from 'decky-frontend-lib'; import { useState } from 'react'; -import { FaShapes } from 'react-icons/fa'; +import { FaShapes, FaTools } from 'react-icons/fa'; import { installFromURL } from '../../../../store'; import BranchSelect from './BranchSelect'; import RemoteDebuggingSettings from './RemoteDebugging'; import UpdaterSettings from './Updater'; -export default function GeneralSettings() { +export default function GeneralSettings({ + isDeveloper, + setIsDeveloper, +}: { + isDeveloper: boolean; + setIsDeveloper: (val: boolean) => void; +}) { const [pluginURL, setPluginURL] = useState(''); // const [checked, setChecked] = useState(false); // store these in some kind of State instead return ( @@ -25,6 +31,18 @@ export default function GeneralSettings() { <BranchSelect /> <RemoteDebuggingSettings /> <Field + label="Developer mode" + description={<span style={{ whiteSpace: 'pre-line' }}>Enables Decky's developer settings.</span>} + icon={<FaTools style={{ display: 'block' }} />} + > + <Toggle + value={isDeveloper} + onChange={(toggleValue) => { + setIsDeveloper(toggleValue); + }} + /> + </Field> + <Field label="Manual plugin install" description={<TextField label={'URL'} value={pluginURL} onChange={(e) => setPluginURL(e?.target.value)} />} icon={<FaShapes style={{ display: 'block' }} />} diff --git a/frontend/src/developer.tsx b/frontend/src/developer.tsx new file mode 100644 index 00000000..b1fe74d6 --- /dev/null +++ b/frontend/src/developer.tsx @@ -0,0 +1,88 @@ +import { + ReactRouter, + Router, + findModule, + findModuleChild, + gamepadDialogClasses, + gamepadSliderClasses, + playSectionClasses, + quickAccessControlsClasses, + quickAccessMenuClasses, + scrollClasses, + scrollPanelClasses, + sleep, + staticClasses, + updaterFieldClasses, +} from 'decky-frontend-lib'; +import { FaReact } from 'react-icons/fa'; + +import Logger from './logger'; +import { getSetting } from './utils/settings'; + +const logger = new Logger('DeveloperMode'); + +let removeSettingsObserver: () => void = () => {}; + +export function setShowValveInternal(show: boolean) { + const settingsMod = findModuleChild((m) => { + if (typeof m !== 'object') return undefined; + for (let prop in m) { + if (typeof m[prop]?.settings?.bIsValveEmail !== 'undefined') return m[prop]; + } + }); + + if (show) { + removeSettingsObserver = settingsMod[ + Object.getOwnPropertySymbols(settingsMod).find((x) => x.toString() == 'Symbol(mobx administration)') as any + ].observe((e: any) => { + e.newValue.bIsValveEmail = true; + }); + settingsMod.m_Settings.bIsValveEmail = true; + logger.log('Enabled Valve Internal menu'); + } else { + removeSettingsObserver(); + settingsMod.m_Settings.bIsValveEmail = false; + logger.log('Disabled Valve Internal menu'); + } +} + +export async function setShouldConnectToReactDevTools(enable: boolean) { + window.DeckyPluginLoader.toaster.toast({ + title: (enable ? 'Enabling' : 'Disabling') + ' React DevTools', + body: 'Reloading in 5 seconds', + icon: <FaReact />, + }); + await sleep(5000); + return enable + ? window.DeckyPluginLoader.callServerMethod('enable_rdt') + : window.DeckyPluginLoader.callServerMethod('disable_rdt'); +} + +export async function startup() { + const isValveInternalEnabled = await getSetting('developer.valve_internal', false); + const isRDTEnabled = await getSetting('developer.rdt.enabled', false); + + if (isValveInternalEnabled) setShowValveInternal(isValveInternalEnabled); + + if ((isRDTEnabled && !window.deckyHasConnectedRDT) || (!isRDTEnabled && window.deckyHasConnectedRDT)) + setShouldConnectToReactDevTools(isRDTEnabled); + + logger.log('Exposing decky-frontend-lib APIs as DFL'); + window.DFL = { + findModuleChild, + findModule, + Router, + ReactRouter, + classes: { + scrollClasses, + staticClasses, + playSectionClasses, + scrollPanelClasses, + updaterFieldClasses, + gamepadDialogClasses, + gamepadSliderClasses, + quickAccessMenuClasses, + quickAccessControlsClasses, + }, + }; +} diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 08b37d15..03010e13 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -1,6 +1,3 @@ -import { ButtonItem, CommonUIModule, webpackCache } from 'decky-frontend-lib'; -import { forwardRef } from 'react'; - import PluginLoader from './plugin-loader'; import { DeckyUpdater } from './updater'; @@ -11,32 +8,12 @@ declare global { importDeckyPlugin: Function; syncDeckyPlugins: Function; deckyHasLoaded: boolean; + deckyHasConnectedRDT?: boolean; deckyAuthToken: string; - webpackJsonp: any; + DFL?: any; } } -// HACK to fix plugins using webpack v4 push - -const v4Cache = {}; -for (let m of Object.keys(webpackCache)) { - v4Cache[m] = { exports: webpackCache[m] }; -} - -if (!window.webpackJsonp || window.webpackJsonp.deckyShimmed) { - window.webpackJsonp = { - deckyShimmed: true, - push: (mod: any): any => { - if (mod[1].get_require) return { c: v4Cache }; - }, - }; - CommonUIModule.__deckyButtonItemShim = forwardRef((props: any, ref: any) => { - // tricks the old filter into working - const dummy = `childrenContainerWidth:"min"`; - return <ButtonItem ref={ref} _shim={dummy} {...props} />; - }); -} - (async () => { window.deckyAuthToken = await fetch('http://127.0.0.1:1337/auth/token').then((r) => r.text()); @@ -44,6 +21,7 @@ if (!window.webpackJsonp || window.webpackJsonp.deckyShimmed) { window.DeckyPluginLoader?.deinit(); window.DeckyPluginLoader = new PluginLoader(); + window.DeckyPluginLoader.init(); window.importDeckyPlugin = function (name: string, version: string) { window.DeckyPluginLoader?.importPlugin(name, version); }; diff --git a/frontend/src/plugin-loader.tsx b/frontend/src/plugin-loader.tsx index 6eee9bc0..400e7484 100644 --- a/frontend/src/plugin-loader.tsx +++ b/frontend/src/plugin-loader.tsx @@ -25,6 +25,7 @@ import { checkForUpdates } from './store'; import TabsHook from './tabs-hook'; import Toaster from './toaster'; import { VerInfo, callUpdaterMethod } from './updater'; +import { getSetting } from './utils/settings'; const StorePage = lazy(() => import('./components/store/Store')); const SettingsPage = lazy(() => import('./components/settings')); @@ -40,7 +41,7 @@ class PluginLoader extends Logger { private tabsHook: TabsHook = new TabsHook(); // private windowHook: WindowHook = new WindowHook(); private routerHook: RouterHook = new RouterHook(); - private toaster: Toaster = new Toaster(); + public toaster: Toaster = new Toaster(); private deckyState: DeckyState = new DeckyState(); private reloadLock: boolean = false; @@ -172,6 +173,12 @@ class PluginLoader extends Logger { } } + public init() { + getSetting('developer.enabled', false).then((val) => { + if (val) import('./developer').then((developer) => developer.startup()); + }); + } + public deinit() { this.routerHook.removeRoute('/decky/store'); this.routerHook.removeRoute('/decky/settings'); diff --git a/frontend/src/toaster.tsx b/frontend/src/toaster.tsx index a55c87e6..55987056 100644 --- a/frontend/src/toaster.tsx +++ b/frontend/src/toaster.tsx @@ -38,7 +38,7 @@ class Toaster extends Logger { await sleep(2000); } - this.node = instance.return.return; + this.node = instance.sibling.child; let toast: any; let renderedToast: ReactNode = null; this.node.stateNode.render = (...args: any[]) => { @@ -68,8 +68,7 @@ class Toaster extends Logger { delete this.node.stateNode.render; } return ret; - }; - this.node.stateNode.forceUpdate(); + }); this.settingsModule = findModuleChild((m) => { if (typeof m !== 'object') return undefined; for (let prop in m) { diff --git a/frontend/src/utils/hooks/useSetting.ts b/frontend/src/utils/hooks/useSetting.ts index f950bf6a..afed5239 100644 --- a/frontend/src/utils/hooks/useSetting.ts +++ b/frontend/src/utils/hooks/useSetting.ts @@ -1,25 +1,14 @@ import { useEffect, useState } from 'react'; -interface GetSettingArgs<T> { - key: string; - default: T; -} - -interface SetSettingArgs<T> { - key: string; - value: T; -} +import { getSetting, setSetting } from '../settings'; -export function useSetting<T>(key: string, def: T): [value: T | null, setValue: (value: T) => Promise<void>] { +export function useSetting<T>(key: string, def: T): [value: T, setValue: (value: T) => Promise<void>] { const [value, setValue] = useState(def); useEffect(() => { (async () => { - const res = (await window.DeckyPluginLoader.callServerMethod('get_setting', { - key, - default: def, - } as GetSettingArgs<T>)) as { result: T }; - setValue(res.result); + const res = await getSetting<T>(key, def); + setValue(res); })(); }, []); @@ -27,10 +16,7 @@ export function useSetting<T>(key: string, def: T): [value: T | null, setValue: value, async (val: T) => { setValue(val); - await window.DeckyPluginLoader.callServerMethod('set_setting', { - key, - value: val, - } as SetSettingArgs<T>); + await setSetting(key, val); }, ]; } diff --git a/frontend/src/utils/settings.ts b/frontend/src/utils/settings.ts new file mode 100644 index 00000000..cadfe935 --- /dev/null +++ b/frontend/src/utils/settings.ts @@ -0,0 +1,24 @@ +interface GetSettingArgs<T> { + key: string; + default: T; +} + +interface SetSettingArgs<T> { + key: string; + value: T; +} + +export async function getSetting<T>(key: string, def: T): Promise<T> { + const res = (await window.DeckyPluginLoader.callServerMethod('get_setting', { + key, + default: def, + } as GetSettingArgs<T>)) as { result: T }; + return res.result; +} + +export async function setSetting<T>(key: string, value: T): Promise<void> { + await window.DeckyPluginLoader.callServerMethod('set_setting', { + key, + value, + } as SetSettingArgs<T>); +} |
