diff options
| author | AAGaming <aa@mail.catvibers.me> | 2022-10-15 23:46:42 -0400 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2022-10-15 20:46:42 -0700 |
| commit | 6e3c05072cb507e2a376b7019836bea7bf663cb0 (patch) | |
| tree | 6171eb40ba1a76e1e2b24f8cac8545114a5ad4ef /backend | |
| parent | 9b405e4bdc6ead86d69ad8f54af89a8d5b484f08 (diff) | |
| download | decky-loader-6e3c05072cb507e2a376b7019836bea7bf663cb0.tar.gz decky-loader-6e3c05072cb507e2a376b7019836bea7bf663cb0.zip | |
Developer menu (#211)v2.3.0-pre1
* 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>
Diffstat (limited to 'backend')
| -rw-r--r-- | backend/injector.py | 196 | ||||
| -rw-r--r-- | backend/utilities.py | 73 |
2 files changed, 259 insertions, 10 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") |
