ServerDrawer: force Quest Dock state via Context value, not hook mutation

This commit is contained in:
everestmcarthur 2026-08-02 10:36:33 -04:00
parent bd0e624256
commit e5d0b685e0
8 changed files with 136 additions and 139 deletions

View file

@ -1,10 +1,6 @@
import { logger } from "@vendetta";
import { storage } from "@vendetta/plugin";
import { patchQuestDockRender } from "./patches/questDockRender";
import { patchQuestDockBase } from "./patches/questDockBase";
import { patchQuestDockVisible } from "./patches/questDockVisible";
import { patchMobileQuestDock } from "./patches/mobileQuestDock";
import { patchGetQuestAsset } from "./patches/getQuestAsset";
import { patchQuestDockContext } from "./patches/questDockContext";
import { patchExpanded, patchEmpty } from "./patches/contentPatch";
import { patchHideGuildsBar } from "./patches/hideGuildsBar";
import { patchCreateElement } from "./lib/createElementIntercept";
@ -36,11 +32,7 @@ function tryPatch(name: string, fn: () => boolean): boolean {
// missing rather than just not loaded yet).
function applyAll() {
const patchers: Record<string, () => boolean> = {
questDockRender: () => patchQuestDockRender(cleanups),
questDockBase: () => patchQuestDockBase(cleanups),
questDockVisible: () => patchQuestDockVisible(cleanups),
mobileQuestDock: () => patchMobileQuestDock(cleanups),
getQuestAsset: () => patchGetQuestAsset(cleanups),
questDockContext: () => patchQuestDockContext(cleanups),
expanded: () => patchExpanded(cleanups),
collapsed: () => patchEmpty("QuestDockContentCollapsed", cleanups),
enrolledHeader: () => patchEmpty("QuestDockEnrolledHeader", cleanups),

View file

@ -12,6 +12,11 @@ interface PropsIntercept {
replacement: React.ComponentType<any> | null;
}
interface PropsTransform {
predicate: (props: any) => boolean;
transform: (props: any) => any;
}
interface TypeDetector {
predicate: (type: any) => boolean;
onDetected: (type: any) => void;
@ -19,6 +24,7 @@ interface TypeDetector {
const intercepts = new Map<React.ComponentType<any>, Intercept>();
const propsIntercepts: PropsIntercept[] = [];
const propsTransforms: PropsTransform[] = [];
let typeDetectors: TypeDetector[] = [];
let isPatched = false;
@ -41,6 +47,23 @@ export function registerPropsIntercept(predicate: (props: any) => boolean, repla
propsIntercepts.push({ predicate, replacement });
}
/**
* Rewrites an element's props in place while keeping the same type - unlike registerPropsIntercept,
* this doesn't swap in a different component, it lets the real one render with different input.
*
* Exists because monkey-patching a hook's exported property (`someModule.useSomeHook = wrapper`)
* only affects callers that go through that exact exports-object reference. Confirmed live (Key
* Inspector fiber capture) that Discord's Quest Dock hooks are called via an internal, same-chunk
* closure reference instead - the patched export sits there unused, and the real, unpatched hook
* result flows through untouched. A React.createElement/jsx call has no such ambiguity: it's always
* the one shared runtime function, called the same way regardless of which chunk the JSX originated
* in - so rewriting the `value` prop of a Context.Provider element here reaches every consumer
* exactly as if the underlying hooks had actually returned that value.
*/
export function registerPropsTransform(predicate: (props: any) => boolean, transform: (props: any) => any) {
propsTransforms.push({ predicate, transform });
}
/**
* 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
@ -86,11 +109,24 @@ function runTypeDetectors(type: any) {
function resolveReplacement(type: any, props: any): { type: any; props: any } | null | undefined {
runTypeDetectors(type);
if (props) {
let effectiveProps = props;
if (effectiveProps) {
for (const { predicate, transform } of propsTransforms) {
try {
if (predicate(effectiveProps)) {
effectiveProps = transform(effectiveProps);
}
} catch {
// A bad transform shouldn't block every other element from rendering.
}
}
}
if (effectiveProps) {
for (const { predicate, replacement } of propsIntercepts) {
try {
if (predicate(props)) {
return replacement ? { type: replacement, props } : null;
if (predicate(effectiveProps)) {
return replacement ? { type: replacement, props: effectiveProps } : null;
}
} catch {
// A bad predicate shouldn't block every other element from rendering.
@ -100,7 +136,11 @@ function resolveReplacement(type: any, props: any): { type: any; props: any } |
const entry = intercepts.get(type);
if (entry) {
return { type: entry.replacement, props: entry.extraProps ? { ...props, ...entry.extraProps } : props };
return { type: entry.replacement, props: entry.extraProps ? { ...effectiveProps, ...entry.extraProps } : effectiveProps };
}
if (effectiveProps !== props) {
return { type, props: effectiveProps };
}
return undefined;
@ -166,6 +206,7 @@ export function patchCreateElement(cleanups: (() => void)[]) {
restoreJsx.forEach((fn) => fn());
intercepts.clear();
propsIntercepts.length = 0;
propsTransforms.length = 0;
typeDetectors = [];
});
}

View file

@ -1,24 +0,0 @@
import { rawFindByFunctionProps } from "../lib/rawFind";
const TAG = "[ServerDrawer]";
// rawFindByFunctionProps, not findByProps or rawFindByProps - see questDockRender.ts for why
// (retried lookup that a caching findByProps would defeat, plus a confirmed-live decoy module
// with the same property names but non-function values that a plain rawFindByProps was matching).
export function patchGetQuestAsset(cleanups: (() => void)[]): boolean {
const mod = rawFindByFunctionProps("getQuestAsset");
if (!mod?.getQuestAsset) {
console.log(TAG, "WARN: getQuestAsset not found");
return false;
}
const orig = mod.getQuestAsset;
mod.getQuestAsset = function (...args: any[]) {
try {
return orig.apply(this, args);
} catch {
return { url: null, isAnimated: false };
}
};
cleanups.push(() => { mod.getQuestAsset = orig; });
return true;
}

View file

@ -1,40 +0,0 @@
import { rawFindByFunctionProps } from "../lib/rawFind";
// rawFindByFunctionProps, not findByProps or rawFindByProps - see questDockRender.ts for why
// (retried lookup that a caching findByProps would defeat, plus a confirmed-live decoy module
// with the same property names but non-function values that a plain rawFindByProps was matching).
export function patchMobileQuestDock(cleanups: (() => void)[]): boolean {
const mod = rawFindByFunctionProps("useMobileQuestDock");
// The upstream version of this check was `!mod.useMobileQuestDock` (no `?.`) - if the lookup
// came back empty, that threw immediately and, since nothing here was wrapped in try/catch,
// took every patch after this one in the onLoad sequence down with it.
if (!mod?.useMobileQuestDock) return false;
const orig = mod.useMobileQuestDock;
mod.useMobileQuestDock = function (...args: any[]) {
const real = orig.apply(this, args);
if (real) return real;
return {
id: "server-drawer",
config: {
quest_content_type: 0,
// Empty strings, not null: confirmed live (crash stack trace) that Discord's real
// getQuestAsset -> resolveAsset -> buildUrl calls .startsWith() on whichever asset
// field it reads, uncaught by this repo's own getQuestAsset try/catch patch (meaning
// useQuestDockHeroAsset calls a reference that patch never actually reaches - likely
// an internal same-module call that bypasses the exports-object property it mutates).
// An empty string is falsy (same as null for any truthiness check upstream) but is a
// valid string .startsWith() can safely be called on, so it can't crash regardless of
// which exact code path reads it.
assets: { questBarHeroVideo: "", questBarHero: "" },
features: [],
},
userStatus: { enrolledAt: "2099-01-01", claimedAt: null },
benefits: { rewards: [] },
guildId: "0",
tasks: [],
};
};
cleanups.push(() => { mod.useMobileQuestDock = orig; });
return true;
}

View file

@ -1,17 +0,0 @@
import { rawFindByFunctionProps } from "../lib/rawFind";
// rawFindByFunctionProps, not findByProps or rawFindByProps - see questDockRender.ts for why
// (retried lookup that a caching findByProps would defeat, plus a confirmed-live decoy module
// with the same property names but non-function values that a plain rawFindByProps was matching).
export function patchQuestDockBase(cleanups: (() => void)[]): boolean {
const mod = rawFindByFunctionProps("useIsMobileQuestDockRenderedBase");
if (!mod?.useIsMobileQuestDockRenderedBase) return false;
const orig = mod.useIsMobileQuestDockRenderedBase;
mod.useIsMobileQuestDockRenderedBase = function (...args: any[]) {
orig.apply(this, args);
return true;
};
cleanups.push(() => { mod.useIsMobileQuestDockRenderedBase = orig; });
return true;
}

View file

@ -0,0 +1,89 @@
import { registerPropsTransform } from "../lib/createElementIntercept";
const TAG = "[ServerDrawer]";
/**
* Replaces every hook-mutation patch that used to live in questDockRender.ts, questDockBase.ts,
* questDockVisible.ts, mobileQuestDock.ts, and getQuestAsset.ts. All five mutated a hook or utility
* function's exported property (`someModule.useSomeHook = wrapper`) - confirmed live (Key
* Inspector fiber capture) that this never reached the real call site at all: Discord's Quest Dock
* hooks call each other via an internal, same-chunk closure reference, not through the exports
* object property these patches mutated. The proof: with every one of those patches active, the
* live Context value at QuestDockExternalCoordinationContextProviderInner still carried a real
* Discord quest snowflake ID, never this plugin's fake "server-drawer" quest.
*
* QuestDockExternalCoordinationContextProviderInner assembles exactly the state we need
* (isRendered, isVisibleToUser, quest) from those hooks and hands it down through a Context
* Provider - and a Provider element is always created through the one shared createElement/jsx
* runtime, the same interception point GuildsBar's patch already relies on successfully. So instead
* of trying to fake the hooks' return values, this rewrites the Provider's `value` prop directly,
* which reaches every consumer exactly as if the hooks themselves had returned it.
*/
export function patchQuestDockContext(cleanups: (() => void)[]): boolean {
registerPropsTransform(
(props) => {
const v = props?.value;
return !!(v && typeof v === "object" &&
"isRendered" in v && "isVisibleToUser" in v && "quest" in v);
},
(props) => {
const v = props.value;
const quest = v.quest ?? fallbackQuest();
return {
...props,
value: {
...v,
isRendered: true,
isVisibleToUser: true,
quest: sanitizeQuestAssets(quest),
},
};
}
);
cleanups.push(() => {
// No per-call unregister needed - createElementIntercept clears all transforms/intercepts
// together when its own patch unwinds, part of this plugin's normal unload/restart cleanup.
});
console.log(TAG, "PATCH: watching for the Quest Dock coordination context");
return true;
}
function fallbackQuest(): any {
return {
id: "server-drawer",
config: {
quest_content_type: 0,
assets: { questBarHeroVideo: "", questBarHero: "" },
features: [],
},
userStatus: { enrolledAt: "2099-01-01", claimedAt: null },
benefits: { rewards: [] },
guildId: "0",
tasks: [],
};
}
// Same fix as the old mobileQuestDock.ts fallback, but now also applied to REAL quest data - a
// real, currently-active quest that has no hero media at all sets these fields to null too
// (confirmed: the crash reproduced with this plugin's own hook patches active, at a point where
// this quest's real ID was flowing through unmodified - meaning the null came from Discord's own
// quest, not this plugin's fallback). Empty string is falsy for any truthiness check upstream, same
// as null, but is a valid string .startsWith() can't throw on.
function sanitizeQuestAssets(quest: any): any {
const assets = quest?.config?.assets;
if (!assets) return quest;
const hero = assets.questBarHero;
const heroVideo = assets.questBarHeroVideo;
if (hero != null && heroVideo != null) return quest;
return {
...quest,
config: {
...quest.config,
assets: {
...assets,
questBarHero: hero ?? "",
questBarHeroVideo: heroVideo ?? "",
},
},
};
}

View file

@ -1,24 +0,0 @@
import { rawFindByFunctionProps } from "../lib/rawFind";
// rawFindByFunctionProps, not rawFindByProps or findByProps. Two separate reasons:
// 1. This gets retried by index.ts's fast-retry loop, and Revenge's own findByProps permanently
// caches a "not found" result and never rescans (see rawFind.ts), which would make the retry
// pointless after the first failed attempt.
// 2. Confirmed live (Key Inspector's Eval console) that plain rawFindByProps was matching a
// completely unrelated, lower-module-id module whose properties happen to share these exact
// names but hold non-function (type-shape-looking object) values - window.modules iterates in
// ascending id order, so that decoy always won over the real, higher-id hook implementation.
// rawFindByFunctionProps additionally requires the matched property to actually be a function,
// which the decoy isn't. See rawFind.ts for the full writeup.
export function patchQuestDockRender(cleanups: (() => void)[]): boolean {
const mod = rawFindByFunctionProps("useIsMobileQuestDockRendered");
if (!mod?.useIsMobileQuestDockRendered) return false;
const orig = mod.useIsMobileQuestDockRendered;
mod.useIsMobileQuestDockRendered = function (...args: any[]) {
orig.apply(this, args);
return true;
};
cleanups.push(() => { mod.useIsMobileQuestDockRendered = orig; });
return true;
}

View file

@ -1,20 +0,0 @@
import { rawFindByFunctionProps } from "../lib/rawFind";
// A third gate alongside useIsMobileQuestDockRendered/useIsMobileQuestDockRenderedBase, found by
// reading the component that actually consumes all three together
// (QuestDockWithQuestContext, confirmed against decompiled current-build Discord source): even
// with the quest data faked and the "rendered" hooks forced true, this one still decides whether
// the dock is visible right now based on navigation route and channel-focus state - so without
// forcing it too, the drawer could still never show up depending on what screen you're on.
export function patchQuestDockVisible(cleanups: (() => void)[]): boolean {
const mod = rawFindByFunctionProps("useIsMobileQuestDockVisibleToUser");
if (!mod?.useIsMobileQuestDockVisibleToUser) return false;
const orig = mod.useIsMobileQuestDockVisibleToUser;
mod.useIsMobileQuestDockVisibleToUser = function (...args: any[]) {
orig.apply(this, args);
return true;
};
cleanups.push(() => { mod.useIsMobileQuestDockVisibleToUser = orig; });
return true;
}