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:
AAGaming
2022-10-15 23:46:42 -04:00
committed by GitHub
parent 9b405e4bdc
commit 6e3c05072c
12 changed files with 527 additions and 80 deletions
+188 -8
View File
@@ -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"]
return res["result"]["result"]["value"]
+71 -2
View File
@@ -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")
+1 -1
View File
@@ -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}
+32 -18
View File
@@ -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} />;
}
@@ -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 { 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 (
@@ -24,6 +30,18 @@ export default function GeneralSettings() {
<UpdaterSettings />
<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)} />}
+88
View File
@@ -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
View File
@@ -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);
};
+8 -1
View File
@@ -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');
+2 -3
View File
@@ -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) {
+5 -19
View File
@@ -1,25 +1,14 @@
import { useEffect, useState } from 'react';
interface GetSettingArgs<T> {
key: string;
default: T;
}
import { getSetting, setSetting } from '../settings';
interface SetSettingArgs<T> {
key: string;
value: T;
}
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);
},
];
}
+24
View File
@@ -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>);
}