Key Inspector: add an eval tool; forceRerender: switch to the navigation-ref nudge
This commit is contained in:
parent
7050778072
commit
1556fdb56b
3 changed files with 160 additions and 56 deletions
85
plugins/key-inspector/src/lib/evalTool.ts
Normal file
85
plugins/key-inspector/src/lib/evalTool.ts
Normal file
|
|
@ -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<string> {
|
||||
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)}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string | null>(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 (
|
||||
<FormSection title="Eval">
|
||||
<View style={{ paddingHorizontal: 16, paddingVertical: 8 }}>
|
||||
<FormInput
|
||||
title="Code"
|
||||
placeholder={'e.g. return findByProps("getRootNavigationRef")'}
|
||||
value={code}
|
||||
onChange={setCode}
|
||||
multiline
|
||||
/>
|
||||
<PrimaryButton
|
||||
label={running ? "Running..." : "Run & copy result"}
|
||||
style={{ marginTop: 8 }}
|
||||
disabled={running}
|
||||
onPress={run}
|
||||
/>
|
||||
{result != null && (
|
||||
<Text style={{ marginTop: 8, fontSize: 12.5, opacity: 0.85 }} selectable>
|
||||
{result.length > 2000 ? `${result.slice(0, 2000)}\n... (truncated, full result copied)` : result}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={{ marginTop: 8, fontSize: 12.5, opacity: 0.7 }}>
|
||||
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.
|
||||
</Text>
|
||||
</View>
|
||||
</FormSection>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiTreeSection() {
|
||||
return (
|
||||
<FormSection title="Full Vendetta API tree">
|
||||
|
|
@ -154,6 +206,7 @@ export default function Settings() {
|
|||
<SettingsScaffold>
|
||||
<FullScanSection />
|
||||
<YouBarDiagnosticsSection />
|
||||
<EvalSection />
|
||||
<ApiTreeSection />
|
||||
<ManualSearchSection />
|
||||
<NoteBox>
|
||||
|
|
|
|||
|
|
@ -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<any> | 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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue