mirror of
https://github.com/SteamDeckHomebrew/decky-loader.git
synced 2026-07-11 13:32:01 +00:00
Developer menu (#211)
* add settings utils to use settings outside of components * initial implementation of developer menu * ✨ Add support for addScriptToEvaluateOnNewDocument * React DevTools support * increase chance of RDT successfully injecting * Rewrite toaster hook to not re-create the window * remove friends focus workaround because it's fixed * Expose various DFL utilities as DFL in dev mode * try to fix text field focuss * move focusable to outside field * add onTouchEnd and onClick to focusable * Update pnpm-lock.yaml Co-authored-by: FinalDoom <7464170-FinalDoom@users.noreply.gitlab.com> Co-authored-by: TrainDoctor <traindoctor@protonmail.com>
This commit is contained in:
+185
-5
@@ -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 asyncio import sleep
|
||||||
from logging import debug, getLogger
|
from logging import getLogger
|
||||||
from traceback import format_exc
|
from traceback import format_exc
|
||||||
|
|
||||||
from aiohttp import ClientSession
|
from aiohttp import ClientSession
|
||||||
@@ -11,7 +11,10 @@ BASE_ADDRESS = "http://localhost:8080"
|
|||||||
|
|
||||||
logger = getLogger("Injector")
|
logger = getLogger("Injector")
|
||||||
|
|
||||||
|
|
||||||
class Tab:
|
class Tab:
|
||||||
|
cmd_id = 0
|
||||||
|
|
||||||
def __init__(self, res) -> None:
|
def __init__(self, res) -> None:
|
||||||
self.title = res["title"]
|
self.title = res["title"]
|
||||||
self.id = res["id"]
|
self.id = res["id"]
|
||||||
@@ -24,14 +27,24 @@ class Tab:
|
|||||||
self.client = ClientSession()
|
self.client = ClientSession()
|
||||||
self.websocket = await self.client.ws_connect(self.ws_url)
|
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 def listen_for_message(self):
|
||||||
async for message in self.websocket:
|
async for message in self.websocket:
|
||||||
yield message
|
data = message.json()
|
||||||
|
yield data
|
||||||
|
|
||||||
async def _send_devtools_cmd(self, dc, receive=True):
|
async def _send_devtools_cmd(self, dc, receive=True):
|
||||||
if self.websocket:
|
if self.websocket:
|
||||||
|
self.cmd_id += 1
|
||||||
|
dc["id"] = self.cmd_id
|
||||||
await self.websocket.send_json(dc)
|
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")
|
raise RuntimeError("Websocket not opened")
|
||||||
|
|
||||||
async def evaluate_js(self, js, run_async=False, manage_socket=True, get_result=True):
|
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()
|
await self.open_websocket()
|
||||||
|
|
||||||
res = await self._send_devtools_cmd({
|
res = await self._send_devtools_cmd({
|
||||||
"id": 1,
|
|
||||||
"method": "Runtime.evaluate",
|
"method": "Runtime.evaluate",
|
||||||
"params": {
|
"params": {
|
||||||
"expression": js,
|
"expression": js,
|
||||||
@@ -49,9 +61,172 @@ class Tab:
|
|||||||
}, get_result)
|
}, get_result)
|
||||||
|
|
||||||
if manage_socket:
|
if manage_socket:
|
||||||
await self.client.close()
|
await self.close_websocket()
|
||||||
return res
|
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):
|
async def get_steam_resource(self, url):
|
||||||
res = await self.evaluate_js(f'(async function test() {{ return await (await fetch("{url}")).text() }})()', True)
|
res = await self.evaluate_js(f'(async function test() {{ return await (await fetch("{url}")).text() }})()', True)
|
||||||
return res["result"]["result"]["value"]
|
return res["result"]["result"]["value"]
|
||||||
@@ -59,6 +234,7 @@ class Tab:
|
|||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return self.title
|
return self.title
|
||||||
|
|
||||||
|
|
||||||
async def get_tabs():
|
async def get_tabs():
|
||||||
async with ClientSession() as web:
|
async with ClientSession() as web:
|
||||||
res = {}
|
res = {}
|
||||||
@@ -80,6 +256,7 @@ async def get_tabs():
|
|||||||
else:
|
else:
|
||||||
raise Exception(f"/json did not return 200. {await res.text()}")
|
raise Exception(f"/json did not return 200. {await res.text()}")
|
||||||
|
|
||||||
|
|
||||||
async def get_tab(tab_name):
|
async def get_tab(tab_name):
|
||||||
tabs = await get_tabs()
|
tabs = await get_tabs()
|
||||||
tab = next((i for i in tabs if i.title == tab_name), None)
|
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")
|
raise ValueError(f"Tab {tab_name} not found")
|
||||||
return tab
|
return tab
|
||||||
|
|
||||||
|
|
||||||
async def inject_to_tab(tab_name, js, run_async=False):
|
async def inject_to_tab(tab_name, js, run_async=False):
|
||||||
tab = await get_tab(tab_name)
|
tab = await get_tab(tab_name)
|
||||||
|
|
||||||
return await tab.evaluate_js(js, run_async)
|
return await tab.evaluate_js(js, run_async)
|
||||||
|
|
||||||
|
|
||||||
async def tab_has_global_var(tab_name, var_name):
|
async def tab_has_global_var(tab_name, var_name):
|
||||||
try:
|
try:
|
||||||
tab = await get_tab(tab_name)
|
tab = await get_tab(tab_name)
|
||||||
@@ -104,6 +283,7 @@ async def tab_has_global_var(tab_name, var_name):
|
|||||||
|
|
||||||
return res["result"]["result"]["value"]
|
return res["result"]["result"]["value"]
|
||||||
|
|
||||||
|
|
||||||
async def tab_has_element(tab_name, element_name):
|
async def tab_has_element(tab_name, element_name):
|
||||||
try:
|
try:
|
||||||
tab = await get_tab(tab_name)
|
tab = await get_tab(tab_name)
|
||||||
|
|||||||
+71
-2
@@ -1,10 +1,13 @@
|
|||||||
import uuid
|
import uuid
|
||||||
import os
|
import os
|
||||||
from json.decoder import JSONDecodeError
|
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 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 helpers
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
@@ -26,9 +29,17 @@ class Utilities:
|
|||||||
"disallow_remote_debugging": self.disallow_remote_debugging,
|
"disallow_remote_debugging": self.disallow_remote_debugging,
|
||||||
"set_setting": self.set_setting,
|
"set_setting": self.set_setting,
|
||||||
"get_setting": self.get_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:
|
if context:
|
||||||
context.web_app.add_routes([
|
context.web_app.add_routes([
|
||||||
web.post("/methods/{method_name}", self._handle_server_method_call)
|
web.post("/methods/{method_name}", self._handle_server_method_call)
|
||||||
@@ -194,3 +205,61 @@ class Utilities:
|
|||||||
"realpath": os.path.realpath(path),
|
"realpath": os.path.realpath(path),
|
||||||
"files": files
|
"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")
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const Toast: FunctionComponent<ToastProps> = ({ toast }) => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{ '--toast-duration': `${toast.nToastDurationMS}ms` } as React.CSSProperties}
|
style={{ '--toast-duration': `${toast.nToastDurationMS}ms` } as React.CSSProperties}
|
||||||
className={joinClassNames(toastClasses.ToastPopup, toastClasses.toastEnter)}
|
className={toastClasses.toastEnter}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
onClick={toast.data.onClick}
|
onClick={toast.data.onClick}
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import { SidebarNavigation } from 'decky-frontend-lib';
|
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 GeneralSettings from './pages/general';
|
||||||
import PluginList from './pages/plugin_list';
|
import PluginList from './pages/plugin_list';
|
||||||
|
|
||||||
|
const DeveloperSettings = lazy(() => import('./pages/developer'));
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
return (
|
const [isDeveloper, setIsDeveloper] = useSetting<boolean>('developer.enabled', false);
|
||||||
<SidebarNavigation
|
|
||||||
title="Decky Settings"
|
const pages = [
|
||||||
showTitle
|
|
||||||
pages={[
|
|
||||||
{
|
{
|
||||||
title: 'General',
|
title: 'General',
|
||||||
content: <GeneralSettings />,
|
content: <GeneralSettings isDeveloper={isDeveloper} setIsDeveloper={setIsDeveloper} />,
|
||||||
route: '/decky/settings/general',
|
route: '/decky/settings/general',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -19,7 +22,18 @@ export default function SettingsPage() {
|
|||||||
content: <PluginList />,
|
content: <PluginList />,
|
||||||
route: '/decky/settings/plugins',
|
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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { useState } from 'react';
|
||||||
import { FaShapes } from 'react-icons/fa';
|
import { FaShapes, FaTools } from 'react-icons/fa';
|
||||||
|
|
||||||
import { installFromURL } from '../../../../store';
|
import { installFromURL } from '../../../../store';
|
||||||
import BranchSelect from './BranchSelect';
|
import BranchSelect from './BranchSelect';
|
||||||
import RemoteDebuggingSettings from './RemoteDebugging';
|
import RemoteDebuggingSettings from './RemoteDebugging';
|
||||||
import UpdaterSettings from './Updater';
|
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 [pluginURL, setPluginURL] = useState('');
|
||||||
// const [checked, setChecked] = useState(false); // store these in some kind of State instead
|
// const [checked, setChecked] = useState(false); // store these in some kind of State instead
|
||||||
return (
|
return (
|
||||||
@@ -24,6 +30,18 @@ export default function GeneralSettings() {
|
|||||||
<UpdaterSettings />
|
<UpdaterSettings />
|
||||||
<BranchSelect />
|
<BranchSelect />
|
||||||
<RemoteDebuggingSettings />
|
<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
|
<Field
|
||||||
label="Manual plugin install"
|
label="Manual plugin install"
|
||||||
description={<TextField label={'URL'} value={pluginURL} onChange={(e) => setPluginURL(e?.target.value)} />}
|
description={<TextField label={'URL'} value={pluginURL} onChange={(e) => setPluginURL(e?.target.value)} />}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+3
-25
@@ -1,6 +1,3 @@
|
|||||||
import { ButtonItem, CommonUIModule, webpackCache } from 'decky-frontend-lib';
|
|
||||||
import { forwardRef } from 'react';
|
|
||||||
|
|
||||||
import PluginLoader from './plugin-loader';
|
import PluginLoader from './plugin-loader';
|
||||||
import { DeckyUpdater } from './updater';
|
import { DeckyUpdater } from './updater';
|
||||||
|
|
||||||
@@ -11,32 +8,12 @@ declare global {
|
|||||||
importDeckyPlugin: Function;
|
importDeckyPlugin: Function;
|
||||||
syncDeckyPlugins: Function;
|
syncDeckyPlugins: Function;
|
||||||
deckyHasLoaded: boolean;
|
deckyHasLoaded: boolean;
|
||||||
|
deckyHasConnectedRDT?: boolean;
|
||||||
deckyAuthToken: string;
|
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 () => {
|
(async () => {
|
||||||
window.deckyAuthToken = await fetch('http://127.0.0.1:1337/auth/token').then((r) => r.text());
|
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?.deinit();
|
||||||
|
|
||||||
window.DeckyPluginLoader = new PluginLoader();
|
window.DeckyPluginLoader = new PluginLoader();
|
||||||
|
window.DeckyPluginLoader.init();
|
||||||
window.importDeckyPlugin = function (name: string, version: string) {
|
window.importDeckyPlugin = function (name: string, version: string) {
|
||||||
window.DeckyPluginLoader?.importPlugin(name, version);
|
window.DeckyPluginLoader?.importPlugin(name, version);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { checkForUpdates } from './store';
|
|||||||
import TabsHook from './tabs-hook';
|
import TabsHook from './tabs-hook';
|
||||||
import Toaster from './toaster';
|
import Toaster from './toaster';
|
||||||
import { VerInfo, callUpdaterMethod } from './updater';
|
import { VerInfo, callUpdaterMethod } from './updater';
|
||||||
|
import { getSetting } from './utils/settings';
|
||||||
|
|
||||||
const StorePage = lazy(() => import('./components/store/Store'));
|
const StorePage = lazy(() => import('./components/store/Store'));
|
||||||
const SettingsPage = lazy(() => import('./components/settings'));
|
const SettingsPage = lazy(() => import('./components/settings'));
|
||||||
@@ -40,7 +41,7 @@ class PluginLoader extends Logger {
|
|||||||
private tabsHook: TabsHook = new TabsHook();
|
private tabsHook: TabsHook = new TabsHook();
|
||||||
// private windowHook: WindowHook = new WindowHook();
|
// private windowHook: WindowHook = new WindowHook();
|
||||||
private routerHook: RouterHook = new RouterHook();
|
private routerHook: RouterHook = new RouterHook();
|
||||||
private toaster: Toaster = new Toaster();
|
public toaster: Toaster = new Toaster();
|
||||||
private deckyState: DeckyState = new DeckyState();
|
private deckyState: DeckyState = new DeckyState();
|
||||||
|
|
||||||
private reloadLock: boolean = false;
|
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() {
|
public deinit() {
|
||||||
this.routerHook.removeRoute('/decky/store');
|
this.routerHook.removeRoute('/decky/store');
|
||||||
this.routerHook.removeRoute('/decky/settings');
|
this.routerHook.removeRoute('/decky/settings');
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class Toaster extends Logger {
|
|||||||
await sleep(2000);
|
await sleep(2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.node = instance.return.return;
|
this.node = instance.sibling.child;
|
||||||
let toast: any;
|
let toast: any;
|
||||||
let renderedToast: ReactNode = null;
|
let renderedToast: ReactNode = null;
|
||||||
this.node.stateNode.render = (...args: any[]) => {
|
this.node.stateNode.render = (...args: any[]) => {
|
||||||
@@ -68,8 +68,7 @@ class Toaster extends Logger {
|
|||||||
delete this.node.stateNode.render;
|
delete this.node.stateNode.render;
|
||||||
}
|
}
|
||||||
return ret;
|
return ret;
|
||||||
};
|
});
|
||||||
this.node.stateNode.forceUpdate();
|
|
||||||
this.settingsModule = findModuleChild((m) => {
|
this.settingsModule = findModuleChild((m) => {
|
||||||
if (typeof m !== 'object') return undefined;
|
if (typeof m !== 'object') return undefined;
|
||||||
for (let prop in m) {
|
for (let prop in m) {
|
||||||
|
|||||||
@@ -1,25 +1,14 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
interface GetSettingArgs<T> {
|
import { getSetting, setSetting } from '../settings';
|
||||||
key: string;
|
|
||||||
default: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SetSettingArgs<T> {
|
export function useSetting<T>(key: string, def: T): [value: T, setValue: (value: T) => Promise<void>] {
|
||||||
key: string;
|
|
||||||
value: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSetting<T>(key: string, def: T): [value: T | null, setValue: (value: T) => Promise<void>] {
|
|
||||||
const [value, setValue] = useState(def);
|
const [value, setValue] = useState(def);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
const res = (await window.DeckyPluginLoader.callServerMethod('get_setting', {
|
const res = await getSetting<T>(key, def);
|
||||||
key,
|
setValue(res);
|
||||||
default: def,
|
|
||||||
} as GetSettingArgs<T>)) as { result: T };
|
|
||||||
setValue(res.result);
|
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -27,10 +16,7 @@ export function useSetting<T>(key: string, def: T): [value: T | null, setValue:
|
|||||||
value,
|
value,
|
||||||
async (val: T) => {
|
async (val: T) => {
|
||||||
setValue(val);
|
setValue(val);
|
||||||
await window.DeckyPluginLoader.callServerMethod('set_setting', {
|
await setSetting(key, val);
|
||||||
key,
|
|
||||||
value: val,
|
|
||||||
} as SetSettingArgs<T>);
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user