YouBar+: catch the component via element-creation interception, not metro search
This commit is contained in:
parent
abb019ae6f
commit
3c42faa98c
5 changed files with 137 additions and 65 deletions
|
|
@ -7,7 +7,7 @@ import { patchMobileQuestDock } from "./patches/mobileQuestDock";
|
|||
import { patchGetQuestAsset } from "./patches/getQuestAsset";
|
||||
import { patchExpanded, patchEmpty } from "./patches/contentPatch";
|
||||
import { patchHideGuildsBar } from "./patches/hideGuildsBar";
|
||||
import { patchCreateElement } from "./patches/createElementIntercept";
|
||||
import { patchCreateElement } from "@shared/lib/createElementIntercept";
|
||||
import { patchAutoCollapseFolders } from "./patches/autoCollapseFolders";
|
||||
import Settings from "./ui/Settings";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { React } from "@vendetta/metro/common";
|
||||
import { rawFind } from "@shared/lib/rawFind";
|
||||
import ServerDrawerSheet from "../components/ServerDrawerSheet";
|
||||
import { registerIntercept } from "./createElementIntercept";
|
||||
import { registerIntercept } from "@shared/lib/createElementIntercept";
|
||||
|
||||
const TAG = "[ServerDrawer]";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { registerPropsIntercept } from "./createElementIntercept";
|
||||
import { registerPropsIntercept } from "@shared/lib/createElementIntercept";
|
||||
|
||||
const TAG = "[ServerDrawer]";
|
||||
|
||||
|
|
|
|||
|
|
@ -5,38 +5,63 @@ import { storage } from "@vendetta/plugin";
|
|||
import { getAssetIDByName } from "@vendetta/ui/assets";
|
||||
import { waitFor } from "@shared/lib/waitFor";
|
||||
import { rawFindByTypeName } from "@shared/lib/rawFind";
|
||||
import { patchCreateElement, registerTypeDetector } from "@shared/lib/createElementIntercept";
|
||||
|
||||
export let updateYouBar = () => {};
|
||||
|
||||
function isYouBarNotificationsButton(type: any): boolean {
|
||||
return (
|
||||
type?.name === "YouBarNotificationsButton" ||
|
||||
type?.displayName === "YouBarNotificationsButton" ||
|
||||
type?.type?.name === "YouBarNotificationsButton" ||
|
||||
type?.type?.displayName === "YouBarNotificationsButton"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmed on-device, not just theorized: the exact upstream one-shot `findByTypeName` lookup at
|
||||
* onLoad is genuinely non-deterministic across app boots - it found the component on one reload and
|
||||
* came up empty on the very next one, same code, same device, nothing else changed. Metro doesn't
|
||||
* guarantee YouBarNotificationsButton's module has been required by the time a plugin's onLoad
|
||||
* runs; which reload wins that race varies. waitFor keeps checking after a first miss instead of
|
||||
* giving up for the whole session - and it has to use the passive rawFindByTypeName specifically,
|
||||
* because Revenge's own findByTypeName permanently caches a negative result and never rescans,
|
||||
* which would make retrying it pointless.
|
||||
* Confirmed on-device that a metro-search-based lookup (whether the original one-shot
|
||||
* findByTypeName, or this repo's own waitFor + passive rawFindByTypeName retry) is fundamentally
|
||||
* racing against something it can't always win: YouBarNotificationsButton is a React.memo'd leaf
|
||||
* that, once mounted, appears to never render again for the rest of the session (also confirmed
|
||||
* on-device - patching it and watching for 20+ real seconds on the main screen never saw another
|
||||
* render). If that first, only mount happens before our search finds and patches the component,
|
||||
* the patch is permanently too late no matter how many times the search retries afterward.
|
||||
*
|
||||
* Deliberately NOT reintroducing anything beyond this. The force-render attempts tried earlier this
|
||||
* session (walking React's DevTools fiber registry, nudging live navigation state) were aimed at a
|
||||
* different problem - buttons not appearing on a hot toggle without ever restarting - and one of
|
||||
* them caused a real regression. This only fixes the reload-timing race; "no restart ever needed"
|
||||
* is a separate, deliberately deferred piece of work.
|
||||
* registerTypeDetector sidesteps the race by watching element *creation* instead of searching the
|
||||
* module registry after the fact: the moment ANY code calls createElement/jsx with this component
|
||||
* as `type` - which is necessarily the actual live reference already, there's no way to call it any
|
||||
* earlier - we grab it and patch immediately, before that render even happens. This still needs
|
||||
* patchCreateElement to have installed its React.createElement/jsx-runtime hooks before that first
|
||||
* call, but jsx-runtime is about as foundational and early-loaded a module as React itself, since
|
||||
* literally nothing can render via JSX without it - a much safer bet than waiting on a specific leaf
|
||||
* UI component to register.
|
||||
*
|
||||
* The waitFor + rawFindByTypeName search stays too, purely as a fallback for the case where the
|
||||
* component was already mounted (and thus already searchable) *before* this plugin's onLoad even
|
||||
* ran - e.g. toggled on well after a cold boot completed - since in that case there's no future
|
||||
* creation call left to intercept. Both paths funnel through the same `patchOnce`, deduped by
|
||||
* reference, so if both somehow fire for the same live component it only gets patched once.
|
||||
*/
|
||||
export default function patchYouBarButtons(): () => void {
|
||||
let unpatch: () => void = () => {};
|
||||
const cleanups: (() => void)[] = [];
|
||||
let buttonUnpatch: () => void = () => {};
|
||||
let patchedTarget: any = null;
|
||||
|
||||
const handle = waitFor(
|
||||
() => rawFindByTypeName("YouBarNotificationsButton"),
|
||||
(YouBarNotificationsButton) => {
|
||||
unpatch = applyButtonPatch(YouBarNotificationsButton);
|
||||
}
|
||||
);
|
||||
function patchOnce(target: any) {
|
||||
if (!target || target === patchedTarget) return;
|
||||
patchedTarget = target;
|
||||
buttonUnpatch = applyButtonPatch(target);
|
||||
}
|
||||
|
||||
patchCreateElement(cleanups);
|
||||
registerTypeDetector(isYouBarNotificationsButton, patchOnce);
|
||||
|
||||
const handle = waitFor(() => rawFindByTypeName("YouBarNotificationsButton"), patchOnce);
|
||||
cleanups.push(() => handle.cancel());
|
||||
|
||||
return () => {
|
||||
handle.cancel();
|
||||
unpatch();
|
||||
buttonUnpatch();
|
||||
cleanups.forEach((fn) => fn());
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -47,42 +72,40 @@ function applyButtonPatch(YouBarNotificationsButton: any): () => void {
|
|||
const SettingsIcon = getAssetIDByName("SettingsIcon");
|
||||
|
||||
return instead("type", YouBarNotificationsButton, (args: any[], OriginalRender: (...a: any[]) => any) => {
|
||||
const [, forceUpdate] = React.useReducer((x: number) => ~x, 0);
|
||||
try {
|
||||
const [, forceUpdate] = React.useReducer((x: number) => ~x, 0);
|
||||
updateYouBar = () => forceUpdate();
|
||||
|
||||
updateYouBar = () => forceUpdate();
|
||||
const res = OriginalRender(...args);
|
||||
if (!res?.props?.children) return res;
|
||||
|
||||
const res = OriginalRender(...args);
|
||||
const IconButton = res.props.children.type;
|
||||
const originalProps = res.props.children.props;
|
||||
|
||||
if (!res?.props?.children) return res;
|
||||
|
||||
const IconButton = res.props.children.type;
|
||||
const originalProps = res.props.children.props;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{storage.showDMButton && (
|
||||
<IconButton
|
||||
variant={originalProps?.variant || "tertiary"}
|
||||
size={originalProps?.size || "sm"}
|
||||
icon={ChatIcon}
|
||||
onPress={() => {
|
||||
transitionModule?.transitionToGuild?.("@me");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{storage.showSettingsButton && (
|
||||
<IconButton
|
||||
variant={originalProps?.variant || "tertiary"}
|
||||
size={originalProps?.size || "sm"}
|
||||
icon={SettingsIcon}
|
||||
onPress={() => {
|
||||
userSettingsAction?.openUserSettings?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{res}
|
||||
</React.Fragment>
|
||||
);
|
||||
return (
|
||||
<React.Fragment>
|
||||
{storage.showDMButton && (
|
||||
<IconButton
|
||||
variant={originalProps?.variant || "tertiary"}
|
||||
size={originalProps?.size || "sm"}
|
||||
icon={ChatIcon}
|
||||
onPress={() => transitionModule?.transitionToGuild?.("@me")}
|
||||
/>
|
||||
)}
|
||||
{storage.showSettingsButton && (
|
||||
<IconButton
|
||||
variant={originalProps?.variant || "tertiary"}
|
||||
size={originalProps?.size || "sm"}
|
||||
icon={SettingsIcon}
|
||||
onPress={() => userSettingsAction?.openUserSettings?.()}
|
||||
/>
|
||||
)}
|
||||
{res}
|
||||
</React.Fragment>
|
||||
);
|
||||
} catch {
|
||||
// A shape mismatch here should mean "stock YouBar, no extra buttons" - not a crash.
|
||||
return OriginalRender(...args);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,8 +11,14 @@ interface PropsIntercept {
|
|||
replacement: React.ComponentType<any> | null;
|
||||
}
|
||||
|
||||
interface TypeDetector {
|
||||
predicate: (type: any) => boolean;
|
||||
onDetected: (type: any) => void;
|
||||
}
|
||||
|
||||
const intercepts = new Map<React.ComponentType<any>, Intercept>();
|
||||
const propsIntercepts: PropsIntercept[] = [];
|
||||
let typeDetectors: TypeDetector[] = [];
|
||||
let isPatched = false;
|
||||
|
||||
export function registerIntercept(
|
||||
|
|
@ -34,8 +40,51 @@ export function registerPropsIntercept(predicate: (props: any) => boolean, repla
|
|||
propsIntercepts.push({ predicate, replacement });
|
||||
}
|
||||
|
||||
/**
|
||||
* Purely observational, one-shot: the first time an element is created whose `type` matches
|
||||
* `predicate`, calls `onDetected(type)` with the real, live type reference and then stops
|
||||
* watching - it never alters what actually renders.
|
||||
*
|
||||
* This exists for components that can't be found reliably by searching Metro's module registry
|
||||
* after the fact (findByTypeName/rawFindByTypeName), because that races against whenever the
|
||||
* component first mounts - if it mounts before the search finds it, and it never mounts again for
|
||||
* the rest of the session (true for some React.memo'd chrome components), the search losing that
|
||||
* race means the patch never gets a chance to apply at all, permanently. Intercepting element
|
||||
* creation instead sidesteps the race entirely: by the time ANYTHING calls createElement/jsx with
|
||||
* this component as `type`, that reference already exists and is already the real one about to be
|
||||
* used - there's no way to observe the call any earlier than this.
|
||||
*/
|
||||
export function registerTypeDetector(predicate: (type: any) => boolean, onDetected: (type: any) => void) {
|
||||
typeDetectors.push({ predicate, onDetected });
|
||||
}
|
||||
|
||||
function runTypeDetectors(type: any) {
|
||||
if (typeDetectors.length === 0) return;
|
||||
const remaining: TypeDetector[] = [];
|
||||
for (const detector of typeDetectors) {
|
||||
let matched = false;
|
||||
try {
|
||||
matched = detector.predicate(type);
|
||||
} catch {
|
||||
// A bad predicate shouldn't block every other detector or every element from rendering.
|
||||
}
|
||||
if (matched) {
|
||||
try {
|
||||
detector.onDetected(type);
|
||||
} catch {
|
||||
// Same - a detector's own handler throwing shouldn't take anything else down.
|
||||
}
|
||||
} else {
|
||||
remaining.push(detector);
|
||||
}
|
||||
}
|
||||
typeDetectors = remaining;
|
||||
}
|
||||
|
||||
/** Returns a replacement type if this element should be intercepted, `null` to render nothing, or `undefined` to pass through unchanged. */
|
||||
function resolveReplacement(type: any, props: any): { type: any; props: any } | null | undefined {
|
||||
runTypeDetectors(type);
|
||||
|
||||
if (props) {
|
||||
for (const { predicate, replacement } of propsIntercepts) {
|
||||
try {
|
||||
|
|
@ -61,7 +110,7 @@ export function patchCreateElement(cleanups: (() => void)[]) {
|
|||
|
||||
const origCreateElement = React.createElement;
|
||||
if (!origCreateElement) {
|
||||
console.warn("[ServerDrawer] React.createElement is null, skipping patch");
|
||||
console.warn("[createElementIntercept] React.createElement is null, skipping patch");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -78,10 +127,9 @@ export function patchCreateElement(cleanups: (() => void)[]) {
|
|||
|
||||
// Modern React/RN builds (Discord's included) compile JSX through the automatic runtime
|
||||
// (jsx/jsxs from react/jsx-runtime) instead of React.createElement - patching only
|
||||
// createElement misses every one of Discord's own render calls entirely, which is why
|
||||
// hideGuildsBar's intercept never actually did anything even though it registered correctly.
|
||||
// Found by shape (jsx/jsxs/Fragment together), not by name, for the same reason everything
|
||||
// else in this file avoids name-based lookups.
|
||||
// createElement misses every one of Discord's own render calls entirely. Found by shape
|
||||
// (jsx/jsxs/Fragment together), not by name, for the same reason everything else in this file
|
||||
// avoids name-based lookups.
|
||||
const jsxRuntime = rawFind((m: any) => typeof m?.jsx === "function" && typeof m?.jsxs === "function" && "Fragment" in m);
|
||||
const restoreJsx: (() => void)[] = [];
|
||||
|
||||
|
|
@ -99,7 +147,7 @@ export function patchCreateElement(cleanups: (() => void)[]) {
|
|||
restoreJsx.push(() => { jsxRuntime[key] = orig; });
|
||||
}
|
||||
} else {
|
||||
console.warn("[ServerDrawer] jsx-runtime module not found, only classic createElement calls will be intercepted");
|
||||
console.warn("[createElementIntercept] jsx-runtime module not found, only classic createElement calls will be intercepted");
|
||||
}
|
||||
|
||||
cleanups.push(() => {
|
||||
|
|
@ -110,5 +158,6 @@ export function patchCreateElement(cleanups: (() => void)[]) {
|
|||
restoreJsx.forEach((fn) => fn());
|
||||
intercepts.clear();
|
||||
propsIntercepts.length = 0;
|
||||
typeDetectors = [];
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue