diff --git a/plugins/key-inspector/src/lib/evalTool.ts b/plugins/key-inspector/src/lib/evalTool.ts new file mode 100644 index 0000000..d542074 --- /dev/null +++ b/plugins/key-inspector/src/lib/evalTool.ts @@ -0,0 +1,85 @@ +import { findByProps, findByName, find } from "@vendetta/metro"; +import { React, ReactNative, FluxDispatcher } from "@vendetta/metro/common"; +import { rawFind, rawFindByTypeName, rawFindByProps, rawFindByName, rawFindByStoreName } from "@shared/lib/rawFind"; + +declare const window: any; + +function safeStringify(value: any): string { + if (value === undefined) return "undefined"; + if (value === null) return "null"; + if (typeof value === "function") return `[Function: ${value.name || "anonymous"}]`; + if (typeof value !== "object") return String(value); + + const seen = new WeakSet(); + try { + return JSON.stringify( + value, + (_key, val) => { + if (typeof val === "function") return `[Function: ${val.name || "anonymous"}]`; + if (typeof val === "symbol") return val.toString(); + if (typeof val === "object" && val !== null) { + if (seen.has(val)) return "[Circular]"; + seen.add(val); + } + return val; + }, + 2 + ); + } catch { + // Most live Discord/React objects (fibers, class instances, refs) have circular structure + // or getters that throw - falling back to a flat key listing still tells you something + // useful instead of just "[object Object]". + try { + return `[unserializable object, keys: ${Object.keys(value).join(", ")}]`; + } catch (e) { + return `[unserializable, and Object.keys threw: ${e}]`; + } + } +} + +/** + * Runs arbitrary code typed into the Eval box, with this repo's own lookup helpers already in + * scope - built so an on-device assumption behind a fix (does this property exist, does this call + * behave the way decompiled source suggested) can be checked in seconds instead of a full + * build/push/update/test cycle. The code runs as the body of an async function, so a bare + * expression, a `return ...`, or `await`-ing something all work. Never runs anything on its own - + * only what's typed in and manually run here. + */ +export async function runEval(code: string): Promise { + try { + const fn = new Function( + "findByProps", + "findByName", + "find", + "rawFind", + "rawFindByTypeName", + "rawFindByProps", + "rawFindByName", + "rawFindByStoreName", + "React", + "ReactNative", + "FluxDispatcher", + "window", + `return (async () => {\n${code}\n})();` + ); + + const result = await fn( + findByProps, + findByName, + find, + rawFind, + rawFindByTypeName, + rawFindByProps, + rawFindByName, + rawFindByStoreName, + React, + ReactNative, + FluxDispatcher, + window + ); + + return safeStringify(result); + } catch (e) { + return `Error: ${e instanceof Error ? `${e.message}\n${e.stack ?? ""}` : String(e)}`; + } +} diff --git a/plugins/key-inspector/src/ui/Settings.tsx b/plugins/key-inspector/src/ui/Settings.tsx index 878fc41..96e492b 100644 --- a/plugins/key-inspector/src/ui/Settings.tsx +++ b/plugins/key-inspector/src/ui/Settings.tsx @@ -8,6 +8,7 @@ import PrimaryButton from "@shared/ui/PrimaryButton"; import { runAllChecks, formatReport } from "../lib/checks"; import { dumpVendettaApiTree } from "../lib/apiTree"; import { runYouBarDiagnostics } from "../lib/youBarDiagnostics"; +import { runEval } from "../lib/evalTool"; const { View, Text } = ReactNative; const { FormSection, FormInput } = Forms; @@ -59,6 +60,57 @@ function YouBarDiagnosticsSection() { ); } +function EvalSection() { + const [code, setCode] = React.useState(""); + const [result, setResult] = React.useState(null); + const [running, setRunning] = React.useState(false); + + const run = async () => { + if (!code.trim() || running) return; + setRunning(true); + try { + const output = await runEval(code); + setResult(output); + clipboard.setString(output); + showToast("Result copied", undefined); + } finally { + setRunning(false); + } + }; + + return ( + + + + + {result != null && ( + + {result.length > 2000 ? `${result.slice(0, 2000)}\n... (truncated, full result copied)` : result} + + )} + + Runs as the body of an async function - a bare expression, `return ...`, or + `await ...` all work. findByProps/findByName/find, the raw* passive variants + (rawFind/rawFindByTypeName/rawFindByProps/rawFindByName/rawFindByStoreName), + React, ReactNative, FluxDispatcher, and window are already in scope. Nothing runs + until you tap the button. + + + + ); +} + function ApiTreeSection() { return ( @@ -154,6 +206,7 @@ export default function Settings() { + diff --git a/shared/lib/forceRerender.ts b/shared/lib/forceRerender.ts index 9ceff15..a8a140b 100644 --- a/shared/lib/forceRerender.ts +++ b/shared/lib/forceRerender.ts @@ -1,3 +1,5 @@ +import { findByProps } from "@vendetta/metro"; + declare const window: any; // The actual reason YouBar+'s buttons only ever showed up after a full reload: YouBar's own @@ -9,20 +11,22 @@ declare const window: any; // to touch it, which on a live device can be "eventually" or "never in this session" - matching // exactly what looked like "needs 2 reloads." // +// The first version of this file tried to force that update by walking React's DevTools fiber-root +// registry (__REACT_DEVTOOLS_GLOBAL_HOOK__). Confirmed via Key Inspector on a real device that +// hook simply doesn't exist on this Discord build at all - React never gets a chance to register +// with it, so that whole approach was silently doing nothing. This version instead uses a real, +// public react-navigation API that Key Inspector also confirmed works: getRootNavigationRef(). +// // This does two things, both best-effort and safe to call redundantly: -// 1. If `target` is a memo wrapper, permanently override its `compare` to always report "props -// changed" - so from this point on, any future reconciliation pass that *does* reach this -// fiber for any reason will never bail out again. -// 2. Force one reconciliation pass to reach it *right now* instead of waiting on (1) alone, via -// React's own DevTools fiber-root registry - which every RN app populates purely because React -// itself reports its roots there, with no real inspector needing to be attached (confirmed -// present in Discord's own bundle via `__REACT_DEVTOOLS_GLOBAL_HOOK__`). Once the live fiber -// for `target` is found, this walks up to the nearest real class-component ancestor and calls -// its public, ordinary `forceUpdate()` - a genuinely scheduled update, so React correctly -// descends all the way down through every bailout on the way to us, instead of skipping this -// part of the tree entirely as "nothing pending here." +// 1. If `target` is a memo wrapper, permanently override its `compare` so any future +// reconciliation pass that reaches it never bails out again. +// 2. Force a pass to reach it *right now* by calling setParams() on the currently-focused route +// with its own unchanged params. That's a genuine react-navigation state update (a new state +// object, not a no-op) - and unlike a plain prop diff, navigation state is read through +// context, which React always propagates through memoized descendants that consume it, instead +// of letting them silently bail like a prop-only update would. // If either step's assumptions don't hold on a given build, it just silently no-ops - the existing -// patch and poll are still in place, so the worst case is unchanged from today (shows up on the +// patch and poll are still in place, so the worst case is unchanged from before (shows up on the // next natural re-render or reload), never a crash. export function forceRerender(target: any): void { try { @@ -34,52 +38,14 @@ export function forceRerender(target: any): void { } try { - const hook = window?.__REACT_DEVTOOLS_GLOBAL_HOOK__; - const renderers = hook?.renderers; - if (!hook?.getFiberRoots || !renderers?.keys) return; + const navRef = findByProps("getRootNavigationRef")?.getRootNavigationRef?.(); + if (!navRef?.isReady?.()) return; - for (const rendererID of renderers.keys()) { - const roots: Set | undefined = hook.getFiberRoots(rendererID); - if (!roots) continue; - - for (const root of roots) { - const fiber = findFiberByType(root.current, target); - if (fiber && bump(fiber)) return; - } + const route = navRef.getCurrentRoute?.(); + if (route && typeof navRef.setParams === "function") { + navRef.setParams(route.params ?? {}); } } catch { - // Best effort - no devtools hook, no fiber roots, or an internal shape change. + // Best effort - no navigation ref yet, or an internal shape change. } } - -/** Iterative (not recursive) so an unusually deep RN fiber tree can't blow the JS call stack. */ -function findFiberByType(root: any, target: any): any { - const stack = root ? [root] : []; - let guard = 0; - - while (stack.length) { - if (++guard > 200_000) return null; // Pathological tree - bail rather than hang. - - const fiber = stack.pop(); - if (!fiber) continue; - if (fiber.type === target || fiber.elementType === target) return fiber; - - if (fiber.child) stack.push(fiber.child); - if (fiber.sibling) stack.push(fiber.sibling); - } - - return null; -} - -/** Walks up from `fiber` to the nearest class component and calls its real forceUpdate(). */ -function bump(fiber: any): boolean { - let node = fiber; - while (node) { - if (typeof node.stateNode?.forceUpdate === "function") { - node.stateNode.forceUpdate(); - return true; - } - node = node.return; - } - return false; -}